Commit c6e17a395e for wordpress.org

commit c6e17a395e440ff0550abfc329a7838d075e0d13
Author: whyisjake <whyisjake@git.wordpress.org>
Date:   Tue Oct 13 19:55:59 2026 +0000

    Security: Introduce the Secrets API.

    Credentials such as API keys and tokens are usually stored as plaintext options, so anyone with a database dump or backup can read them. The Secrets API gives plugins and core a place to store them encrypted instead, using `wp_set_secret()`, `wp_get_secret()`, `wp_delete_secret()` and related functions. Each secret keeps its previous version, so a credential can be rotated without breaking the code that reads it.

    By default, secrets are encrypted with libsodium and stored in the options table. The encryption key never goes in the database: it comes from a `WP_SECRETS_KEY` constant in `wp-config.php`, or is derived from the `LOGGED_IN_KEY` and `LOGGED_IN_SALT` constants when that is not defined.

    Hosts can use a different method by adding a `wp-content/secrets.php` drop-in, which loads just before must-use plugins, in the same way `object-cache.php` replaces the object cache. A drop-in can hand all storage to an external secrets manager, or replace only where secrets are stored or only where the encryption key comes from, such as a key management service. If a drop-in is broken, every Secrets API call returns a `WP_Error` rather than falling back to the default method, so a misconfigured host shows up as an error rather than as secrets that appear not to exist. Conformance test suites are included for drop-in authors.

    Also adds the `manage_secrets` capability, granted to administrators through `populate_roles_720()` and `upgrade_720()`, the `manage_network_secrets` meta capability for super admins, and Site Health tests for the Secrets API.

    This provides the encrypted storage requested in #64789, so the Connectors screen's API keys can move off plaintext options in a follow-up. The admin screen is deferred to 7.3, and the WP-CLI commands stay in the feature plugin.

    Props ericmann, westonruter, mindctrl, audrasjb, whyisjake.
    Fixes #66187. See #64789.

    Built from https://develop.svn.wordpress.org/trunk@64337


    git-svn-id: http://core.svn.wordpress.org/trunk@63487 1a063a9b-81f0-0310-95a4-ce76da25c4cd

diff --git a/wp-admin/includes/class-wp-site-health.php b/wp-admin/includes/class-wp-site-health.php
index 31e940b207..de0d7cd8da 100644
--- a/wp-admin/includes/class-wp-site-health.php
+++ b/wp-admin/includes/class-wp-site-health.php
@@ -7,6 +7,9 @@
  * @since 5.2.0
  */

+
+require_once __DIR__ . '/secrets-site-health.php';
+
 #[AllowDynamicProperties]
 class WP_Site_Health {
 	private static $instance = null;
diff --git a/wp-admin/includes/schema.php b/wp-admin/includes/schema.php
index 8ea0b36802..5dc0b43252 100644
--- a/wp-admin/includes/schema.php
+++ b/wp-admin/includes/schema.php
@@ -732,6 +732,7 @@ function populate_roles() {
 	populate_roles_270();
 	populate_roles_280();
 	populate_roles_300();
+	populate_roles_720();

 	// Save the updated roles to the database.
 	if ( $original_use_db ) {
@@ -968,6 +969,19 @@ function populate_roles_300() {
 	}
 }

+/**
+ * Create and modify WordPress roles for WordPress 7.2.
+ *
+ * @since 7.2.0
+ */
+function populate_roles_720(): void {
+	$role = get_role( 'administrator' );
+
+	if ( ! empty( $role ) ) {
+		$role->add_cap( 'manage_secrets' );
+	}
+}
+
 if ( ! function_exists( 'install_network' ) ) :
 	/**
 	 * Install Network.
diff --git a/wp-admin/includes/secrets-site-health.php b/wp-admin/includes/secrets-site-health.php
new file mode 100644
index 0000000000..65ffe28869
--- /dev/null
+++ b/wp-admin/includes/secrets-site-health.php
@@ -0,0 +1,367 @@
+<?php
+/**
+ * Secrets API: Site Health integration
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * Registers the Secrets API's Site Health tests.
+ *
+ * No settings screen – the proposal defers that to 7.3 – but the health signal an
+ * operator needs to notice a broken key, a weak key source, or a pending rotation is
+ * in scope now.
+ *
+ * @since 7.2.0
+ *
+ * @param array<string, array<string, array<string, mixed>>> $tests Existing Site Health tests.
+ * @return array<string, array<string, array<string, mixed>>>
+ */
+function wp_secrets_site_health_tests( $tests ) {
+	$tests['direct']['secrets_api_key_source'] = array(
+		'label' => __( 'Secrets API key source' ),
+		'test'  => 'wp_secrets_site_health_test_key_source',
+	);
+
+	$tests['direct']['secrets_api_undecryptable'] = array(
+		'label' => __( 'Secrets API: undecryptable secrets' ),
+		'test'  => 'wp_secrets_site_health_test_undecryptable',
+	);
+
+	$tests['direct']['secrets_api_needs_rotation'] = array(
+		'label' => __( 'Secrets API: credentials pending rotation' ),
+		'test'  => 'wp_secrets_site_health_test_needs_rotation',
+	);
+
+	return $tests;
+}
+add_filter( 'site_status_tests', 'wp_secrets_site_health_tests' );
+
+/**
+ * Builds the standard shape a Site Health test callback returns.
+ *
+ * @since 7.2.0
+ *
+ * @param string $test        The test's own identifier, matching the key registered
+ *                            in wp_secrets_site_health_tests().
+ * @param string $label       Short label for the test result.
+ * @param string $status      One of 'good', 'recommended', 'critical'.
+ * @param string $description HTML description, normally one or more <p> elements.
+ * @return array
+ *
+ * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string}
+ * @phpstan-param 'good'|'recommended'|'critical' $status
+ */
+function wp_secrets_site_health_result( string $test, string $label, string $status, string $description ): array {
+	$colors = array(
+		'good'        => 'green',
+		'recommended' => 'orange',
+		'critical'    => 'red',
+	);
+
+	return array(
+		'test'        => $test,
+		'label'       => $label,
+		'status'      => $status,
+		'badge'       => array(
+			'label' => __( 'Security' ),
+			'color' => isset( $colors[ $status ] ) ? $colors[ $status ] : 'blue',
+		),
+		'description' => $description,
+	);
+}
+
+/**
+ * Site Health test: is the site key a dedicated constant, or a weaker fallback?
+ *
+ * @since 7.2.0
+ *
+ * @return array
+ *
+ * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string}
+ */
+function wp_secrets_site_health_test_key_source(): array {
+	$key_source = _wp_secrets_get_key_manager()->get_keyring()->get_key_source();
+
+	if ( false !== strpos( $key_source, 'broken' ) ) {
+		return wp_secrets_site_health_result(
+			'secrets_api_key_source',
+			__( 'Secrets API key source is broken' ),
+			'critical',
+			sprintf(
+				'<p>%s</p>',
+				esc_html__( 'The active keyring did not load correctly. No secret can be encrypted or decrypted until this is fixed.' )
+			)
+		);
+	}
+
+	if ( false !== strpos( $key_source, 'legacy interpretation' ) ) {
+		return wp_secrets_site_health_result(
+			'secrets_api_key_source',
+			__( 'Secrets API key should be regenerated' ),
+			'recommended',
+			sprintf(
+				'<p>%s</p>',
+				esc_html__( 'WP_SECRETS_KEY is defined, but not in the recommended base64-encoded 32-byte form. Regenerate it with `wp secret generate-key`.' )
+			)
+		);
+	}
+
+	if ( false !== strpos( $key_source, 'LOGGED_IN_KEY' ) ) {
+		return wp_secrets_site_health_result(
+			'secrets_api_key_source',
+			__( 'Secrets API is using a fallback key' ),
+			'recommended',
+			sprintf(
+				'<p>%s</p>',
+				esc_html__( 'No dedicated WP_SECRETS_KEY constant is defined. Secrets are encrypted using a key derived from existing salts, which works but is not the recommended configuration. Define WP_SECRETS_KEY with a value from `wp secret generate-key`.' )
+			)
+		);
+	}
+
+	return wp_secrets_site_health_result(
+		'secrets_api_key_source',
+		__( 'Secrets API is using a dedicated key' ),
+		'good',
+		sprintf( '<p>%s</p>', esc_html( $key_source ) )
+	);
+}
+
+/**
+ * Site Health test: does every stored secret still decrypt?
+ *
+ * Network secrets are included only for a super admin on a multisite install –
+ * never shown to a site administrator who is not one.
+ *
+ * @since 7.2.0
+ *
+ * @return array
+ *
+ * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string}
+ */
+function wp_secrets_site_health_test_undecryptable(): array {
+	$broken = wp_secrets_site_health_find_undecryptable( false );
+
+	if ( is_multisite() && is_super_admin() ) {
+		$broken = array_merge( $broken, wp_secrets_site_health_find_undecryptable( true ) );
+	}
+
+	if ( empty( $broken ) ) {
+		return wp_secrets_site_health_result(
+			'secrets_api_undecryptable',
+			__( 'All secrets can be decrypted' ),
+			'good',
+			sprintf( '<p>%s</p>', esc_html__( 'Every stored secret decrypted successfully.' ) )
+		);
+	}
+
+	$items = '';
+
+	foreach ( $broken as $entry ) {
+		$items .= sprintf(
+			'<li>%s (%s)</li>',
+			esc_html( $entry['name'] ),
+			sprintf(
+				/* translators: %s: A secret's last known fingerprint. */
+				esc_html__( 'fingerprint: %s' ),
+				esc_html( $entry['fingerprint'] )
+			)
+		);
+	}
+
+	return wp_secrets_site_health_result(
+		'secrets_api_undecryptable',
+		__( 'Some secrets cannot be decrypted' ),
+		'critical',
+		sprintf(
+			'<p>%s</p><ul>%s</ul><p>%s</p>',
+			esc_html__( 'The following secrets exist but could not be decrypted with the current key. There is no way to recover the original value. The credential must be re-entered.' ),
+			$items,
+			esc_html__( 'This can happen after losing WP_SECRETS_KEY, restoring a database backup without its matching key, or a failed key rotation.' )
+		)
+	);
+}
+
+/**
+ * Finds secrets that fail to decrypt, for one scope.
+ *
+ * @since 7.2.0
+ *
+ * @param bool $network Whether to check network-scope secrets.
+ * @return array List of array( 'name' => ..., 'fingerprint' => ... ).
+ *
+ * @phpstan-return list<array{name: string, fingerprint: string}>
+ */
+function wp_secrets_site_health_find_undecryptable( bool $network ): array {
+	$entries = $network ? wp_list_network_secrets() : wp_list_secrets();
+
+	if ( is_wp_error( $entries ) ) {
+		return array();
+	}
+
+	$broken = array();
+
+	foreach ( $entries as $entry ) {
+		$secret = $network ? wp_get_network_secret( $entry['name'] ) : wp_get_secret( $entry['name'] );
+
+		if ( is_wp_error( $secret ) ) {
+			$broken[] = array(
+				'name'        => $entry['name'],
+				'fingerprint' => $entry['fingerprint'],
+			);
+		}
+	}
+
+	return $broken;
+}
+
+/**
+ * Site Health test: are any secrets flagged as needing rotation?
+ *
+ * @since 7.2.0
+ *
+ * @return array
+ *
+ * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string}
+ */
+function wp_secrets_site_health_test_needs_rotation(): array {
+	$count = wp_secrets_site_health_count_needing_rotation( false );
+
+	if ( is_multisite() && is_super_admin() ) {
+		$count += wp_secrets_site_health_count_needing_rotation( true );
+	}
+
+	if ( 0 === $count ) {
+		return wp_secrets_site_health_result(
+			'secrets_api_needs_rotation',
+			__( 'No secrets are pending rotation' ),
+			'good',
+			sprintf( '<p>%s</p>', esc_html__( 'No secret is flagged as needing rotation.' ) )
+		);
+	}
+
+	return wp_secrets_site_health_result(
+		'secrets_api_needs_rotation',
+		__( 'Some secrets are pending rotation' ),
+		'recommended',
+		sprintf(
+			'<p>%s</p>',
+			esc_html(
+				sprintf(
+					/* translators: %d: Number of secrets flagged for rotation. */
+					_n(
+						'%d secret was imported from an existing option and is flagged for rotation. A credential that sat in a plain option has already been through whatever backups and replication paths that option went through; re-entering it with a new value is recommended.',
+						'%d secrets were imported from existing options and are flagged for rotation. A credential that sat in a plain option has already been through whatever backups and replication paths that option went through; re-entering each with a new value is recommended.',
+						$count
+					),
+					$count
+				)
+			)
+		)
+	);
+}
+
+/**
+ * Counts secrets flagged needs_rotation, for one scope.
+ *
+ * @since 7.2.0
+ *
+ * @param bool $network Whether to check network-scope secrets.
+ * @return int
+ *
+ * @phpstan-return int<0, max>
+ */
+function wp_secrets_site_health_count_needing_rotation( bool $network ): int {
+	$entries = $network ? wp_list_network_secrets() : wp_list_secrets();
+
+	if ( is_wp_error( $entries ) ) {
+		return 0;
+	}
+
+	$count = 0;
+
+	foreach ( $entries as $entry ) {
+		if ( ! empty( $entry['needs_rotation'] ) ) {
+			++$count;
+		}
+	}
+
+	return $count;
+}
+
+/**
+ * Adds a Secrets API section to Site Health's debug information.
+ *
+ * Counts and class names only – no secret values, and no fingerprints. Network
+ * scope figures are included only for a super admin on a multisite install.
+ *
+ * @since 7.2.0
+ *
+ * @param array<string, array<string, mixed>> $info Existing debug information sections.
+ * @return array<string, array<string, mixed>>
+ */
+function wp_secrets_site_health_debug_info( $info ) {
+	$key_manager = _wp_secrets_get_key_manager();
+	$provider    = _wp_secrets_get_provider();
+
+	$fields = array(
+		'dropin_active'  => array(
+			'label' => __( 'Drop-in active' ),
+			'value' => wp_using_secrets_dropin() ? __( 'Yes' ) : __( 'No' ),
+		),
+		'provider_class' => array(
+			'label' => __( 'Provider class' ),
+			'value' => get_class( $provider ),
+		),
+		'protected_by'   => array(
+			'label' => __( 'Secrets protected by' ),
+			'value' => $provider->get_label(),
+		),
+		'protection_at'  => array(
+			'label' => __( 'Encryption boundary' ),
+			'value' => WP_Secrets_Provider::BOUNDARY_WORDPRESS === $provider->get_protection_boundary()
+				? __( 'WordPress' )
+				: __( 'The provider (outside WordPress)' ),
+		),
+		'writable'       => array(
+			'label' => __( 'Accepts writes' ),
+			'value' => $provider->is_writable() ? __( 'Yes' ) : __( 'No' ),
+		),
+		'keyring_class'  => array(
+			'label' => __( 'Keyring class' ),
+			'value' => get_class( $key_manager->get_keyring() ),
+		),
+		'key_source'     => array(
+			'label' => __( 'Key source' ),
+			'value' => $key_manager->get_keyring()->get_key_source(),
+		),
+		'record_version' => array(
+			'label' => __( 'Record format version' ),
+			'value' => (string) WP_SECRETS_RECORD_VERSION,
+		),
+	);
+
+	$site_secrets                = wp_list_secrets();
+	$fields['site_secret_count'] = array(
+		'label' => __( 'Site secrets' ),
+		'value' => is_wp_error( $site_secrets ) ? __( 'Unavailable' ) : (string) count( $site_secrets ),
+	);
+
+	if ( is_multisite() && is_super_admin() ) {
+		$network_secrets                = wp_list_network_secrets();
+		$fields['network_secret_count'] = array(
+			'label' => __( 'Network secrets' ),
+			'value' => is_wp_error( $network_secrets ) ? __( 'Unavailable' ) : (string) count( $network_secrets ),
+		);
+	}
+
+	$info['secrets-api'] = array(
+		'label'  => __( 'Secrets API' ),
+		'fields' => $fields,
+	);
+
+	return $info;
+}
+add_filter( 'debug_information', 'wp_secrets_site_health_debug_info' );
diff --git a/wp-admin/includes/upgrade.php b/wp-admin/includes/upgrade.php
index 461ab78a21..f4315e681e 100644
--- a/wp-admin/includes/upgrade.php
+++ b/wp-admin/includes/upgrade.php
@@ -892,6 +892,10 @@ function upgrade_all() {
 		upgrade_700();
 	}

+	if ( $wp_current_db_version < 64337 ) {
+		upgrade_720();
+	}
+
 	maybe_disable_link_manager();

 	maybe_disable_automattic_widgets();
@@ -2502,6 +2506,23 @@ function upgrade_700() {
 	}
 }

+/**
+ * Executes changes made in WordPress 7.2.
+ *
+ * @ignore
+ * @since 7.2.0
+ *
+ * @global int $wp_current_db_version The old (current) database version.
+ */
+function upgrade_720(): void {
+	global $wp_current_db_version;
+
+	// Grant the Secrets API's site-level capability to administrators.
+	if ( $wp_current_db_version < 64337 ) {
+		populate_roles_720();
+	}
+}
+
 /**
  * Executes network-level upgrade routines.
  *
diff --git a/wp-includes/capabilities.php b/wp-includes/capabilities.php
index a1193dc336..70eb1756f4 100644
--- a/wp-includes/capabilities.php
+++ b/wp-includes/capabilities.php
@@ -767,6 +767,7 @@ function map_meta_cap( $cap, $user_id, ...$args ) {
 		case 'manage_network_plugins':
 		case 'manage_network_themes':
 		case 'manage_network_options':
+		case 'manage_network_secrets':
 		case 'upgrade_network':
 			$caps[] = $cap;
 			break;
diff --git a/wp-includes/load.php b/wp-includes/load.php
index d827afa5ba..f736afb5b6 100644
--- a/wp-includes/load.php
+++ b/wp-includes/load.php
@@ -824,6 +824,70 @@ function wp_using_ext_object_cache( $using = null ) {
 	return $current_using;
 }

+/**
+ * Loads the secrets.php drop-in, if one exists, and records whether it left the
+ * Secrets API's provider, store, and keyring overrides in a usable state.
+ *
+ * Runs once, before must-use plugins load, so that every interface a drop-in's
+ * classes implement already exists and every plugin sees the drop-in's provider.
+ *
+ * Sets `$GLOBALS['wp_secrets_dropin_loaded']` as soon as the file is found, whether
+ * or not it then loads cleanly: wp_using_secrets_dropin() reports presence, not
+ * health.
+ *
+ * Sets `$GLOBALS['wp_secrets_dropin_broken']` when the require throws, or when any of
+ * `$GLOBALS['wp_secrets_provider']`, `$GLOBALS['wp_secrets_store']`, or
+ * `$GLOBALS['wp_secrets_keyring']` is set to something that does not implement its
+ * interface. A global left unset is fine: a drop-in that overrides only the keyring
+ * leaves the other two alone. Once the flag is set, every Secrets API operation
+ * returns WP_Error for the rest of the request rather than falling back to the
+ * default provider, so a broken drop-in reads as unreachable, never as absent.
+ *
+ * PHP treats a class that implements an interface but omits a required method as an
+ * uncatchable fatal error, even inside the try/catch around the require. That case
+ * cannot be intercepted from PHP code.
+ *
+ * @since 7.2.0
+ * @access private
+ */
+function wp_load_secrets_dropin(): void {
+	static $loaded = false;
+
+	if ( $loaded ) {
+		return;
+	}
+
+	$loaded = true;
+
+	$dropin_path = WP_CONTENT_DIR . '/secrets.php';
+
+	if ( ! file_exists( $dropin_path ) ) {
+		return;
+	}
+
+	$GLOBALS['wp_secrets_dropin_loaded'] = true;
+
+	try {
+		require $dropin_path;
+	} catch ( Throwable $e ) {
+		$GLOBALS['wp_secrets_dropin_broken'] = true;
+
+		return;
+	}
+
+	if ( isset( $GLOBALS['wp_secrets_store'] ) && ! ( $GLOBALS['wp_secrets_store'] instanceof WP_Secrets_Store ) ) {
+		$GLOBALS['wp_secrets_dropin_broken'] = true;
+	}
+
+	if ( isset( $GLOBALS['wp_secrets_keyring'] ) && ! ( $GLOBALS['wp_secrets_keyring'] instanceof WP_Secrets_Keyring ) ) {
+		$GLOBALS['wp_secrets_dropin_broken'] = true;
+	}
+
+	if ( isset( $GLOBALS['wp_secrets_provider'] ) && ! ( $GLOBALS['wp_secrets_provider'] instanceof WP_Secrets_Provider ) ) {
+		$GLOBALS['wp_secrets_dropin_broken'] = true;
+	}
+}
+
 /**
  * Starts the WordPress object cache.
  *
diff --git a/wp-includes/secrets.php b/wp-includes/secrets.php
new file mode 100644
index 0000000000..99d87ed0d7
--- /dev/null
+++ b/wp-includes/secrets.php
@@ -0,0 +1,805 @@
+<?php
+/**
+ * Secrets API
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * The store or key backend is misbehaving.
+ *
+ * The secret exists and was decrypted, but the backend it came from failed in a way
+ * none of the more specific codes below describe.
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_ERROR_DECRYPTION_FAILED', 'secret_decryption_failed' );
+
+/**
+ * The key needed to encrypt or decrypt a secret could not be obtained.
+ *
+ * The keyring is unreachable, misconfigured, or refusing to answer.
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_ERROR_KEY_UNAVAILABLE', 'secret_key_unavailable' );
+
+/**
+ * The storage back end could not be reached at all.
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_ERROR_STORE_UNAVAILABLE', 'secret_store_unavailable' );
+
+/**
+ * A secret name failed wp_secrets_validate_name().
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_ERROR_INVALID_NAME', 'secret_invalid_name' );
+
+/**
+ * A secret value was not a string.
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_ERROR_INVALID_VALUE', 'secret_invalid_value' );
+
+/**
+ * A caller passed an argument this API cannot act on.
+ *
+ * An unrecognized version constant, or an invalid scope, slot, or site id.
+ *
+ * Distinct from the codes above in cause rather than in severity: those describe
+ * a runtime condition a correct caller can still hit (a name that failed
+ * validation, an unavailable key), while this one only ever means the calling
+ * code is wrong. Every site that returns it also calls _doing_it_wrong(), so the
+ * mistake is visible in development rather than only in a return value the caller
+ * may not be checking.
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_ERROR_INVALID_ARGUMENT', 'secret_invalid_argument' );
+
+/**
+ * The cipher this API depends on is unavailable.
+ *
+ * Neither libsodium nor its sodium_compat fallback is present in this environment.
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE', 'secret_crypto_unavailable' );
+
+/**
+ * A stored record could not be parsed as a secret record at all.
+ *
+ * Distinct from decrypting incorrectly, which is WP_SECRETS_ERROR_DECRYPTION_FAILED.
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_ERROR_RECORD_MALFORMED', 'secret_record_malformed' );
+
+/**
+ * A stored record parses, but its format version is not one this API understands.
+ *
+ * Distinct from WP_SECRETS_ERROR_RECORD_MALFORMED so that an operator, or Site
+ * Health, can tell a corrupt record apart from one written by a newer version of
+ * this plugin than is currently active. See
+ * docs/decisions/0006-record-format-v2-not-read-compatible.md. The upgrade path for a future v2 is not
+ * designed, so an unrecognized version is rejected outright rather than guessed at.
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_ERROR_RECORD_UNSUPPORTED_VERSION', 'secret_record_unsupported_version' );
+
+/**
+ * The active provider does not accept writes.
+ *
+ * Its credentials are managed by host tooling, a control panel, or a key policy
+ * outside WordPress. Distinct from WP_SECRETS_ERROR_STORE_UNAVAILABLE, which means
+ * the write could have succeeded and did not. This one means it was never possible.
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_ERROR_PROVIDER_READ_ONLY', 'secret_provider_read_only' );
+
+/**
+ * The current record format version.
+ *
+ * Bumped only if the record shape in WP_Secrets_Cipher's slot arrays ever changes
+ * incompatibly.
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_RECORD_VERSION', 1 );
+
+/**
+ * The longest a secret name may be.
+ *
+ * The wp_options.option_name column allows 191 characters. The longest prefix this
+ * API stores under is '_wp_network_secret_' (19 characters), so 191 - 19 = 172
+ * is the longest name any store built on core's options tables can accept
+ * without truncation, and every store is held to the same limit for
+ * consistency regardless of what a specific backend could technically fit.
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_MAX_NAME_LENGTH', 172 );
+
+/**
+ * Capability required to manage site-scope secrets at the operator boundary.
+ *
+ * Checked by WP-CLI and by a future admin screen, never by the function-level API
+ * itself. See wp_set_secret() for why.
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_CAP_MANAGE', 'manage_secrets' );
+
+/**
+ * Capability required at the operator boundary to manage network-scope secrets.
+ *
+ * @since 7.2.0
+ */
+define( 'WP_SECRETS_CAP_MANAGE_NETWORK', 'manage_network_secrets' );
+
+/**
+ * Best-effort clearing of a plaintext value from memory.
+ *
+ * This is hygiene, not a guarantee. PHP strings are reference-counted and often
+ * shared by copy-on-write; sodium_memzero() can only scrub the bytes of a string it
+ * exclusively owns; if another reference to the same value exists elsewhere,
+ * PHP forces a private copy before zeroing rather than corrupting the shared buffer,
+ * and that other reference is left completely untouched. Call this on every plaintext
+ * local as soon as it is no longer needed, and do not keep incidental copies around.
+ *
+ * Under sodium_compat, core's documented fallback when the libsodium extension is
+ * unavailable, this scrubs nothing. A userland polyfill cannot reach a PHP string's
+ * underlying memory at all. Overwriting the local binding is the only thing available
+ * in that case, and it removes the live reference from this scope without touching
+ * the memory the string used to occupy.
+ *
+ * @since 7.2.0
+ *
+ * @param string $value The value to clear, by reference.
+ */
+function wp_secrets_memzero( &$value ): void {
+	if ( ! is_string( $value ) ) {
+		return;
+	}
+
+	if ( function_exists( 'sodium_memzero' ) ) {
+		try {
+			/*
+			 * sodium_memzero() is called through a true reference alias, not
+			 * directly on $value, so that a static analyzer's stub for
+			 * sodium_memzero() (whose own by-ref parameter is documented as
+			 * becoming null) is evaluated against a local variable rather than
+			 * against this function's own by-ref parameter. The alias is not a
+			 * copy: `=&` shares the identical underlying storage, so this still
+			 * zeroes the real buffer the caller holds.
+			 */
+			$alias =& $value;
+			sodium_memzero( $alias );
+		} catch ( \Throwable $e ) {
+			// A value this function doesn't exclusively own (a shared,
+			// copy-on-write buffer) can legitimately refuse. The unconditional
+			// reassignment below still runs regardless of what happened here.
+			unset( $e );
+		}
+	}
+
+	// Reached on every path, including sodium_memzero() success (it leaves the
+	// variable null, not an empty string) so callers see one consistent contract.
+	$value = '';
+}
+
+/**
+ * Validates a secret's name.
+ *
+ * Names take the form 'plugin-slug/secret-name': lowercase alphanumerics,
+ * hyphens, and underscores in each segment, exactly one '/' separating them,
+ * and no segment starting or ending with a hyphen or underscore.
+ *
+ * Namespacing is a matter of organization, not security. It groups secrets by owner
+ * so that listings and admin screens can be sensible. It confers no isolation: any
+ * plugin that can run PHP can read any secret, namespaced or not.
+ *
+ * An unnamespaced name ('secret-name', no '/') is accepted, but reports through
+ * _doing_it_wrong(). It exists for one reason: code written against the earlier
+ * prototype used a flat keyspace, and refusing those names outright would mean
+ * every such call site has to be rewritten before it can be ported at all.
+ * Accepting them keeps that migration incremental. Nothing else should use one.
+ * Two plugins that both choose 'api-key' collide silently, which is a real problem
+ * even though it is not a security one.
+ *
+ * @since 7.2.0
+ *
+ * @param string $name Candidate secret name.
+ * @return true|WP_Error True if $name is usable, including the unnamespaced form.
+ *                       Otherwise WP_Error with code WP_SECRETS_ERROR_INVALID_NAME.
+ */
+function wp_secrets_validate_name( string $name ) {
+	if ( '' === $name ) {
+		return new WP_Error(
+			WP_SECRETS_ERROR_INVALID_NAME,
+			__( 'Secret names must not be empty.' )
+		);
+	}
+
+	if ( strlen( $name ) > WP_SECRETS_MAX_NAME_LENGTH ) {
+		return new WP_Error(
+			WP_SECRETS_ERROR_INVALID_NAME,
+			sprintf(
+				/* translators: %d: Maximum allowed length, in characters. */
+				__( 'Secret names must be %d characters or fewer.' ),
+				WP_SECRETS_MAX_NAME_LENGTH
+			)
+		);
+	}
+
+	$slashes = substr_count( $name, '/' );
+
+	if ( $slashes > 1 ) {
+		return new WP_Error(
+			WP_SECRETS_ERROR_INVALID_NAME,
+			__( 'Secret names may contain at most one "/", separating a namespace from a key.' )
+		);
+	}
+
+	$segment_pattern = '/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/';
+
+	if ( 0 === $slashes ) {
+		if ( ! preg_match( $segment_pattern, $name ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_INVALID_NAME,
+				__( 'Secret names may contain only lowercase letters, numbers, hyphens, and underscores, and must not start or end with a hyphen or underscore.' )
+			);
+		}
+
+		/*
+		 * Reported here rather than at each public entry point: this is the one
+		 * place that decides what a name is, so a caller cannot reach the store
+		 * with an unnamespaced name without passing through it.
+		 */
+		_doing_it_wrong(
+			__FUNCTION__,
+			sprintf(
+				/* translators: %s: The unnamespaced secret name. */
+				__( '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.' ),
+				$name
+			),
+			'7.2.0'
+		);
+
+		return true;
+	}
+
+	$segments  = explode( '/', $name );
+	$namespace = $segments[0];
+	$key       = $segments[1];
+
+	if ( '' === $namespace || '' === $key ) {
+		return new WP_Error(
+			WP_SECRETS_ERROR_INVALID_NAME,
+			__( 'Both segments of a secret name must be non-empty.' )
+		);
+	}
+
+	if ( ! preg_match( $segment_pattern, $namespace ) || ! preg_match( $segment_pattern, $key ) ) {
+		return new WP_Error(
+			WP_SECRETS_ERROR_INVALID_NAME,
+			__( 'Secret name segments may contain only lowercase letters, numbers, hyphens, and underscores, and must not start or end with a hyphen or underscore.' )
+		);
+	}
+
+	return true;
+}
+
+/**
+ * Returns the active secret store.
+ *
+ * No filter is applied here, or anywhere on the retrieval path. See
+ * docs/spec/extension-points.md. A secrets.php drop-in overrides the store by setting
+ * $GLOBALS['wp_secrets_store'] to an instance before this is first called, and
+ * that global is checked here directly, not through a hook.
+ *
+ * If the global was set but is not a valid WP_Secrets_Store, or if the drop-in
+ * itself failed to load cleanly, this returns WP_Secrets_Broken_Store rather than
+ * silently using the default: the drop-in's presence signals the operator wants
+ * storage other than local options, and falling back to local options anyway would
+ * be the exact silent downgrade this API refuses to make.
+ *
+ * @since 7.2.0
+ *
+ * @return WP_Secrets_Store
+ */
+function _wp_secrets_get_store(): WP_Secrets_Store {
+	static $store = null;
+
+	if ( $store instanceof WP_Secrets_Store ) {
+		return $store;
+	}
+
+	if ( ! empty( $GLOBALS['wp_secrets_dropin_broken'] ) ) {
+		$store = new WP_Secrets_Broken_Store();
+
+		return $store;
+	}
+
+	if ( isset( $GLOBALS['wp_secrets_store'] ) && $GLOBALS['wp_secrets_store'] instanceof WP_Secrets_Store ) {
+		$store = $GLOBALS['wp_secrets_store'];
+
+		return $store;
+	}
+
+	$store = new WP_Secrets_Option_Store();
+
+	return $store;
+}
+
+/**
+ * Returns the active key manager.
+ *
+ * A secrets.php drop-in overrides the keyring by setting
+ * $GLOBALS['wp_secrets_keyring'] to an instance before this is first called. See
+ * _wp_secrets_get_store() for why an invalid override or a failed drop-in load
+ * fails closed (WP_Secrets_Broken_Keyring) rather than falling back to the default.
+ *
+ * @since 7.2.0
+ *
+ * @return WP_Secrets_Key_Manager
+ */
+function _wp_secrets_get_key_manager(): WP_Secrets_Key_Manager {
+	static $key_manager = null;
+
+	if ( $key_manager instanceof WP_Secrets_Key_Manager ) {
+		return $key_manager;
+	}
+
+	if ( ! empty( $GLOBALS['wp_secrets_dropin_broken'] ) ) {
+		$key_manager = new WP_Secrets_Key_Manager( new WP_Secrets_Broken_Keyring() );
+
+		return $key_manager;
+	}
+
+	$keyring = null;
+
+	if ( isset( $GLOBALS['wp_secrets_keyring'] ) && $GLOBALS['wp_secrets_keyring'] instanceof WP_Secrets_Keyring ) {
+		$keyring = $GLOBALS['wp_secrets_keyring'];
+	}
+
+	$key_manager = new WP_Secrets_Key_Manager( $keyring );
+
+	return $key_manager;
+}
+
+/**
+ * Whether a secrets.php drop-in is present and was loaded.
+ *
+ * True whether or not the drop-in successfully provided a store or keyring
+ * override. It reports presence rather than health; Site Health reports separately
+ * on whether a loaded drop-in is actually working.
+ *
+ * @since 7.2.0
+ *
+ * @return bool
+ */
+function wp_using_secrets_dropin(): bool {
+	return ! empty( $GLOBALS['wp_secrets_dropin_loaded'] );
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+/**
+ * Encrypts and stores a secret.
+ *
+ * Encryption is unconditional: there is no plaintext mode and no constant to
+ * disable it. No capability check is applied here, because this must be callable from
+ * cron, REST, and front-end requests where no user is logged in. Enforce
+ * capabilities at the operator boundary (CLI, an admin screen) instead.
+ *
+ * @since 7.2.0
+ *
+ * @param string $name  The secret's namespaced name ('plugin-slug/secret-name').
+ * @param string $value The plaintext value to store.
+ * @return true|WP_Error
+ */
+function wp_set_secret( string $name, $value ) {
+	return _wp_secrets_set( $name, $value, false );
+}
+
+/**
+ * Imports an existing option's value as a secret.
+ *
+ * For an explicit migration, "on their own explicit upgrade schedule,"
+ * in the proposal's words, because "core cannot reliably tell which options are
+ * credentials, and guessing would break sites." The source option is left
+ * untouched: this reads it, it does not move or delete it.
+ *
+ * The imported secret is flagged needs_rotation, unconditionally. A credential that
+ * sat in a plain option has already been through however many backups and
+ * replication paths that option went through; re-encrypting it here does not undo
+ * that, and the flag exists so an operator (or a future admin screen) knows to
+ * actually rotate the value rather than considering the migration finished.
+ *
+ * @since 7.2.0
+ *
+ * @param string $option The existing option's name.
+ * @param string $name   The secret's namespaced name to store it under.
+ * @return true|WP_Error
+ */
+function wp_import_option_as_secret( string $option, string $name ) {
+	if ( '' === $option ) {
+		return new WP_Error(
+			WP_SECRETS_ERROR_INVALID_VALUE,
+			__( 'The option name must not be empty.' )
+		);
+	}
+
+	$value = get_option( $option, null );
+
+	if ( null === $value ) {
+		return new WP_Error(
+			WP_SECRETS_ERROR_INVALID_VALUE,
+			__( 'The option does not exist.' )
+		);
+	}
+
+	if ( ! is_string( $value ) ) {
+		return new WP_Error(
+			WP_SECRETS_ERROR_INVALID_VALUE,
+			__( 'The option value is not a string and cannot be imported as a secret.' )
+		);
+	}
+
+	return _wp_secrets_set( $name, $value, false, true, 'imported' );
+}
+
+
+
+/**
+ * Retrieves a secret.
+ *
+ * Three states, never collapsed: a WP_Secret if it exists and decrypts, null if it
+ * does not exist, WP_Error if it exists but could not be retrieved. No capability
+ * check is applied here. See wp_set_secret().
+ *
+ * @since 7.2.0
+ *
+ * @param string $name    The secret's namespaced name.
+ * @param string $version A WP_Secret_Version constant. Default WP_Secret_Version::CURRENT.
+ * @return WP_Secret|null|WP_Error
+ *
+ * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version
+ */
+function wp_get_secret( string $name, string $version = WP_Secret_Version::CURRENT ) {
+	return _wp_secrets_get( $name, $version, false );
+}
+
+/**
+ * Deletes a secret.
+ *
+ * @since 7.2.0
+ *
+ * @param string $name The secret's namespaced name.
+ * @return true|WP_Error
+ */
+function wp_delete_secret( string $name ) {
+	return _wp_secrets_delete( $name, false );
+}
+
+/**
+ * Clears a secret's previous version, retiring it for good.
+ *
+ * An explicit operator action, with no timers and no cron. Calling this on a secret
+ * that has never been rotated, or that does not exist, is a successful no-op:
+ * the previous slot is already absent either way.
+ *
+ * Beyond the API surface the proposal names.
+ *
+ * @since 7.2.0
+ *
+ * @param string $name The secret's namespaced name.
+ * @return true|WP_Error
+ */
+function wp_retire_secret_version( string $name ) {
+	return _wp_secrets_retire( $name, false );
+}
+
+/**
+ * Lists secrets by name and metadata. Never a value.
+ *
+ * Beyond the API surface the proposal names, and justified by its statement that
+ * the hooks and accessors a future admin screen needs are in scope now, even though
+ * the screen itself is not.
+ *
+ * @since 7.2.0
+ *
+ * @param string $namespace Only secrets whose name starts with "{$namespace}/" are
+ *                          returned. Default '' returns every secret.
+ * @return array|WP_Error Array of associative arrays, each with keys 'name',
+ *                        'fingerprint', 'created', 'has_previous', and
+ *                        'needs_rotation'.
+ *
+ * @phpstan-return list<array{name: string, fingerprint: string, created: int, has_previous: bool, needs_rotation: bool}>|WP_Error
+ */
+function wp_list_secrets( string $namespace = '' ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.namespaceFound -- $namespace is the parameter name the proposal specifies.
+	return _wp_secrets_list( $namespace, false );
+}
+
+/**
+ * Encrypts and stores a network-scope secret.
+ *
+ * Site secrets and network secrets are separate functions with separate
+ * capabilities and separate storage prefixes, with no implicit fallback from one
+ * scope to the other. The proposal describes network scope but does not name these
+ * functions; these are this implementation's names for them.
+ *
+ * @since 7.2.0
+ *
+ * @param string $name  The secret's namespaced name.
+ * @param string $value The plaintext value to store.
+ * @return true|WP_Error
+ */
+function wp_set_network_secret( string $name, $value ) {
+	return _wp_secrets_set( $name, $value, true );
+}
+
+/**
+ * Retrieves a network-scope secret.
+ *
+ * @since 7.2.0
+ *
+ * @param string $name    The secret's namespaced name.
+ * @param string $version A WP_Secret_Version constant. Default WP_Secret_Version::CURRENT.
+ * @return WP_Secret|null|WP_Error
+ *
+ * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version
+ */
+function wp_get_network_secret( string $name, string $version = WP_Secret_Version::CURRENT ) {
+	return _wp_secrets_get( $name, $version, true );
+}
+
+/**
+ * Deletes a network-scope secret.
+ *
+ * @since 7.2.0
+ *
+ * @param string $name The secret's namespaced name.
+ * @return true|WP_Error
+ */
+function wp_delete_network_secret( string $name ) {
+	return _wp_secrets_delete( $name, true );
+}
+
+/**
+ * Clears a network-scope secret's previous version.
+ *
+ * @since 7.2.0
+ *
+ * @param string $name The secret's namespaced name.
+ * @return true|WP_Error
+ */
+function wp_retire_network_secret_version( string $name ) {
+	return _wp_secrets_retire( $name, true );
+}
+
+/**
+ * Lists network-scope secrets by name and metadata. Never a value.
+ *
+ * @since 7.2.0
+ *
+ * @param string $namespace Only secrets whose name starts with "{$namespace}/" are
+ *                          returned. Default '' returns every network-scope secret.
+ * @return array|WP_Error
+ *
+ * @phpstan-return list<array{name: string, fingerprint: string, created: int, has_previous: bool, needs_rotation: bool}>|WP_Error
+ */
+function wp_list_network_secrets( string $namespace = '' ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.namespaceFound -- matches wp_list_secrets()'s own signature.
+	return _wp_secrets_list( $namespace, true );
+}
+
+/**
+ * Returns the active provider.
+ *
+ * The provider is whatever is responsible for holding, protecting, and answering for
+ * this site's secrets.
+ *
+ * A secrets.php drop-in overrides it by setting $GLOBALS['wp_secrets_provider'] to
+ * an instance before this is first called. Absent that, the provider WordPress
+ * ships is assembled from the active store and keyring, which a drop-in can also
+ * replace individually, so wanting host-managed key custody with default storage needs
+ * no provider at all.
+ *
+ * No filter is applied here, or anywhere on the retrieval path. Substitution is by
+ * replacement, never by interception: a filter that can observe which provider
+ * answers is one step from a filter that can answer instead.
+ *
+ * Cached for the request. A failed drop-in fails closed, returning WP_Error from
+ * every operation, rather than silently reverting to the default, because a
+ * misconfigured credential backend must never look like a working one that happens
+ * to be empty.
+ *
+ * @since 7.2.0
+ *
+ * @return WP_Secrets_Provider
+ */
+function _wp_secrets_get_provider(): WP_Secrets_Provider {
+	static $provider = null;
+
+	if ( $provider instanceof WP_Secrets_Provider ) {
+		return $provider;
+	}
+
+	if ( ! empty( $GLOBALS['wp_secrets_dropin_broken'] ) ) {
+		$provider = new WP_Secrets_Broken_Provider();
+
+		return $provider;
+	}
+
+	if ( isset( $GLOBALS['wp_secrets_provider'] ) ) {
+		/*
+		 * Set, but not a provider. Failing closed here rather than falling through to
+		 * the default is the whole point: a platform drop-in that names its class
+		 * wrongly would otherwise have every secret served from wp_options, where none
+		 * of them exist, and a site whose credentials are held elsewhere would report
+		 * them absent rather than unreachable.
+		 */
+		if ( ! ( $GLOBALS['wp_secrets_provider'] instanceof WP_Secrets_Provider ) ) {
+			$provider = new WP_Secrets_Broken_Provider();
+
+			return $provider;
+		}
+
+		$provider = $GLOBALS['wp_secrets_provider'];
+
+		return $provider;
+	}
+
+	$provider = new WP_Secrets_Libsodium_Provider(
+		_wp_secrets_get_store(),
+		_wp_secrets_get_key_manager()
+	);
+
+	return $provider;
+}
+
+/**
+ * Whether the active provider accepts writes.
+ *
+ * Exists so a settings screen can disable its own save control before an operator
+ * types a credential into a field that will only reject it. A provider whose
+ * credentials are managed by host tooling or a control panel reports false.
+ *
+ * @since 7.2.0
+ *
+ * @return bool
+ */
+function wp_secrets_provider_is_writable(): bool {
+	return _wp_secrets_get_provider()->is_writable();
+}
+
+/**
+ * A human-readable description of what is protecting this site's secrets.
+ *
+ * For Site Health and a future admin screen. Never key material, never a value.
+ *
+ * @since 7.2.0
+ *
+ * @return string
+ */
+function wp_secrets_provider_label(): string {
+	return _wp_secrets_get_provider()->get_label();
+}
+
+/**
+ * Shared implementation behind wp_set_secret() and wp_set_network_secret().
+ *
+ * @since 7.2.0
+ *
+ * @param string      $name            The secret's name.
+ * @param string      $value           The plaintext value.
+ * @param bool        $network         Whether this is a network-scope secret.
+ * @param bool        $needs_rotation  Flag the stored secret as needing rotation.
+ * @param string|null $action_override Overrides the $action reported to
+ *                                     wp_secret_changed.
+ * @return true|WP_Error
+ */
+function _wp_secrets_set( string $name, $value, bool $network, bool $needs_rotation = false, ?string $action_override = null ) {
+	return _wp_secrets_get_provider()->set( $name, $value, $network, $needs_rotation, $action_override );
+}
+
+/**
+ * Shared implementation behind wp_get_secret() and wp_get_network_secret().
+ *
+ * @since 7.2.0
+ *
+ * @param string $name    The secret's name.
+ * @param string $version A WP_Secret_Version constant.
+ * @param bool   $network Whether this is a network-scope secret.
+ * @return WP_Secret|null|WP_Error
+ *
+ * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version
+ */
+function _wp_secrets_get( string $name, string $version, bool $network ) {
+	/*
+	 * Checked here rather than inside the provider: this is a contract between
+	 * the API and its caller, and asking every provider to re-validate a core
+	 * constant would be duplicated work that a third-party provider could
+	 * silently skip.
+	 */
+	if ( ! in_array( $version, array( WP_Secret_Version::CURRENT, WP_Secret_Version::PREVIOUS ), true ) ) {
+		_doing_it_wrong(
+			__FUNCTION__,
+			__( 'The version must be WP_Secret_Version::CURRENT or WP_Secret_Version::PREVIOUS.' ),
+			'7.2.0'
+		);
+
+		return new WP_Error(
+			WP_SECRETS_ERROR_INVALID_ARGUMENT,
+			__( 'The version must be WP_Secret_Version::CURRENT or WP_Secret_Version::PREVIOUS.' )
+		);
+	}
+
+	return _wp_secrets_get_provider()->get( $name, $version, $network );
+}
+
+/**
+ * Shared implementation behind wp_delete_secret() and wp_delete_network_secret().
+ *
+ * @since 7.2.0
+ *
+ * @param string $name    The secret's name.
+ * @param bool   $network Whether this is a network-scope secret.
+ * @return true|WP_Error
+ */
+function _wp_secrets_delete( string $name, bool $network ) {
+	return _wp_secrets_get_provider()->delete( $name, $network );
+}
+
+/**
+ * Shared implementation behind wp_retire_secret_version() and its network twin.
+ *
+ * @since 7.2.0
+ *
+ * @param string $name    The secret's name.
+ * @param bool   $network Whether this is a network-scope secret.
+ * @return true|WP_Error
+ */
+function _wp_secrets_retire( string $name, bool $network ) {
+	return _wp_secrets_get_provider()->retire_previous( $name, $network );
+}
+
+/**
+ * Shared implementation behind wp_list_secrets() and wp_list_network_secrets().
+ *
+ * @since 7.2.0
+ *
+ * @param string $name_prefix Restrict to names beginning with this prefix.
+ * @param bool   $network     Whether to list network-scope secrets.
+ * @return array|WP_Error
+ *
+ * @phpstan-return list<array{name: string, fingerprint: string, created: int, has_previous: bool, needs_rotation: bool}>|WP_Error
+ */
+function _wp_secrets_list( string $name_prefix, bool $network ) {
+	return _wp_secrets_get_provider()->list_secrets( $name_prefix, $network );
+}
diff --git a/wp-includes/secrets/class-wp-secret-version.php b/wp-includes/secrets/class-wp-secret-version.php
new file mode 100644
index 0000000000..fbbeca759a
--- /dev/null
+++ b/wp-includes/secrets/class-wp-secret-version.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * Secrets API: WP_Secret_Version class
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * Identifies which slot of a secret's two-slot history to operate on.
+ *
+ * String constants on a final class rather than a native enum: enums require PHP 8.1,
+ * and this class ships in core, whose minimum supported PHP version is 7.4.
+ *
+ * @since 7.2.0
+ */
+final class WP_Secret_Version {
+
+	/**
+	 * The current value of a secret.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const CURRENT = 'current';
+
+	/**
+	 * The value a secret held immediately before its most recent overwrite.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const PREVIOUS = 'previous';
+}
diff --git a/wp-includes/secrets/class-wp-secret.php b/wp-includes/secrets/class-wp-secret.php
new file mode 100644
index 0000000000..260990c3a6
--- /dev/null
+++ b/wp-includes/secrets/class-wp-secret.php
@@ -0,0 +1,342 @@
+<?php
+/**
+ * Secrets API: WP_Secret class
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * A decrypted secret value, returned by wp_get_secret().
+ *
+ * Masking is total and unconditional. Every representation short of an explicit call
+ * to reveal() yields the placeholder '[secret:{name}]' – printing, logging, JSON
+ * encoding, or dumping an instance never exposes the plaintext.
+ *
+ * The plaintext is never a declared property of this class. It lives in a private
+ * static table keyed by spl_object_id(), which is what makes the masking unconditional
+ * rather than something every magic method has to individually get right: var_export()
+ * in particular ignores __debugInfo() and __toString() and serializes declared
+ * properties directly, so if the plaintext lived in one there would be no way to mask
+ * it there at all.
+ *
+ * @since 7.2.0
+ */
+final class WP_Secret implements JsonSerializable {
+
+	/**
+	 * Plaintext values, keyed by the owning instance's spl_object_id().
+	 *
+	 * Deliberately not an SplObjectStorage keyed by the instance itself: that holds a
+	 * strong reference to its keys, which would keep every WP_Secret ever constructed
+	 * alive – and its plaintext un-zeroed – for the life of the request. Keying by
+	 * the integer id instead leaves an instance's own reference count to govern its
+	 * lifetime normally, so __destruct() fires and zeroes the value the moment nothing
+	 * else references the instance.
+	 *
+	 * @since 7.2.0
+	 * @var array<int, string>
+	 */
+	private static $vault = array();
+
+	/**
+	 * The secret's namespaced name.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	private $name;
+
+	/**
+	 * Keyed fingerprint of the plaintext.
+	 *
+	 * Not sensitive. It is a one-way, per-site, per-key digest rather than the value
+	 * itself.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	private $fingerprint;
+
+	/**
+	 * Why this secret's value is unavailable to PHP, or null when it is available.
+	 *
+	 * Set only for a secret a provider can prove exists but will not hand over –
+	 * an HSM that will sign with a key but never export it, a broker that releases
+	 * a credential to a subsystem rather than to WordPress. Such a secret is still
+	 * a real secret with a real name and fingerprint; what it is not is readable,
+	 * and reveal() says so rather than returning something misleading.
+	 *
+	 * @since 7.2.0
+	 * @var WP_Error|null
+	 */
+	private $withheld_reason = null;
+
+	/**
+	 * Constructor.
+	 *
+	 * Throws, where the rest of this API returns WP_Error for a caller mistake.
+	 * That is not an inconsistency to be tidied up later: a constructor has no
+	 * return channel, so the only alternatives are throwing or building a
+	 * half-valid WP_Secret and letting it fail somewhere less obvious. Every
+	 * function that *can* return WP_Error does – see
+	 * this file's own reasoning below. The same rule covers the serialization
+	 * and clone refusals below, which are magic methods with the same problem
+	 * and an additional one: silently permitting them would leak a plaintext.
+	 *
+	 * Nothing outside this API constructs a WP_Secret in normal use; the values
+	 * passed here come from _wp_secrets_get() having just decrypted them.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name        The secret's namespaced name.
+	 * @param string $value       The decrypted plaintext.
+	 * @param string $fingerprint Keyed fingerprint of $value.
+	 *
+	 * @throws InvalidArgumentException If $name or $fingerprint is empty, or if $value
+	 *                                  is not a string. $value may be empty.
+	 */
+	public function __construct( string $name, $value, string $fingerprint ) {
+		if ( '' === $name ) {
+			throw new InvalidArgumentException( 'WP_Secret requires a non-empty name.' );
+		}
+
+		if ( ! is_string( $value ) ) {
+			throw new InvalidArgumentException( 'WP_Secret values must be strings.' );
+		}
+
+		if ( '' === $fingerprint ) {
+			throw new InvalidArgumentException( 'WP_Secret requires a non-empty fingerprint.' );
+		}
+
+		$this->name        = $name;
+		$this->fingerprint = $fingerprint;
+
+		self::$vault[ spl_object_id( $this ) ] = $value;
+	}
+
+	/**
+	 * Returns the decrypted plaintext.
+	 *
+	 * This is the only path to the stored plaintext anywhere in the API.
+	 *
+	 * Returns WP_Error for a secret whose provider will not release the value to
+	 * PHP at all – see withheld(). That case does not arise for the provider
+	 * WordPress ships, which decrypts eagerly and therefore always has a value in
+	 * hand, so most callers will never see it. It is in the signature because a
+	 * return type cannot be widened after adoption: leaving reveal() as
+	 * `string` would permanently foreclose brokered and use-only credentials,
+	 * which is a decision better made deliberately than by omission.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string|WP_Error The plaintext, or WP_Error if this provider does not
+	 *                         release values to PHP.
+	 */
+	public function reveal() {
+		if ( null !== $this->withheld_reason ) {
+			return $this->withheld_reason;
+		}
+
+		return self::$vault[ spl_object_id( $this ) ];
+	}
+
+	/**
+	 * Builds a secret whose value exists but is not available to PHP.
+	 *
+	 * For a provider that can prove a credential exists, and can name and
+	 * fingerprint it, but will not release it – typically because releasing it
+	 * would defeat the point of where it is held. Everything except reveal()
+	 * behaves normally, so such a secret still lists, still reports a stable
+	 * fingerprint, and still masks itself in every output path.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string   $name        The secret's name.
+	 * @param string   $fingerprint A stable per-site digest, from the provider.
+	 * @param WP_Error $reason      Why the value cannot be produced. Returned
+	 *                              verbatim by reveal().
+	 *
+	 * @throws InvalidArgumentException If $name or $fingerprint fail the constructor's
+	 *                                  checks.
+	 *
+	 * @return WP_Secret
+	 */
+	public static function withheld( string $name, string $fingerprint, WP_Error $reason ): WP_Secret {
+		$secret = new self( $name, '', $fingerprint );
+
+		$secret->withheld_reason = $reason;
+
+		return $secret;
+	}
+
+	/**
+	 * Returns the keyed fingerprint of the plaintext.
+	 *
+	 * Stable for a given value on a given site; not a value-recovery oracle.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 */
+	public function fingerprint(): string {
+		return $this->fingerprint;
+	}
+
+	/**
+	 * Returns the secret's namespaced name.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 */
+	public function get_name(): string {
+		return $this->name;
+	}
+
+	/**
+	 * Builds the masked placeholder used everywhere a plaintext would otherwise appear.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 *
+	 * @phpstan-return non-falsy-string
+	 */
+	private function mask(): string {
+		return '[secret:' . $this->name . ']';
+	}
+
+	/**
+	 * Masks the instance for string conversion.
+	 *
+	 * Covers error_log( $secret ) and any other implicit string coercion.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 *
+	 * @phpstan-return non-falsy-string
+	 */
+	public function __toString(): string {
+		return $this->mask();
+	}
+
+	/**
+	 * Masks the instance for var_dump().
+	 *
+	 * Note that print_r() and var_export() have no equivalent hook. They are safe
+	 * regardless, because the plaintext is never a declared property for them to
+	 * enumerate; they will surface the (non-sensitive) name and fingerprint rather
+	 * than this placeholder. See docs/journal/open-questions.md for the documented
+	 * var_export() limitation.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return array<string, string>
+	 */
+	public function __debugInfo(): array {
+		return array( 'value' => $this->mask() );
+	}
+
+	/**
+	 * Masks the instance for json_encode().
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 *
+	 * @phpstan-return non-falsy-string
+	 */
+	public function jsonSerialize(): string {
+		return $this->mask();
+	}
+
+	/**
+	 * Refuses serialization outright.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @throws LogicException Always.
+	 *
+	 * @return void
+	 */
+	public function __sleep() {
+		throw new LogicException( 'WP_Secret cannot be serialized.' );
+	}
+
+	/**
+	 * Refuses unserialization outright.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @throws LogicException Always.
+	 *
+	 * @return void
+	 */
+	public function __wakeup() {
+		throw new LogicException( 'WP_Secret cannot be unserialized.' );
+	}
+
+	/**
+	 * Refuses serialization outright.
+	 *
+	 * PHP calls this in preference to __sleep() when both are defined, so both must
+	 * refuse independently.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @throws LogicException Always.
+	 *
+	 * @return void
+	 */
+	public function __serialize() {
+		throw new LogicException( 'WP_Secret cannot be serialized.' );
+	}
+
+	/**
+	 * Refuses unserialization outright.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param array<mixed> $data Ignored.
+	 *
+	 * @throws LogicException Always.
+	 *
+	 * @return void
+	 */
+	public function __unserialize( array $data ): void {
+		throw new LogicException( 'WP_Secret cannot be unserialized.' );
+	}
+
+	/**
+	 * Refuses cloning outright.
+	 *
+	 * A clone would be a second, unaudited reference to the same plaintext with an
+	 * independent lifetime – and, since the vault is keyed by object id rather than
+	 * copied, a bare `clone` would otherwise leave the clone's reveal() silently
+	 * reading nothing.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @throws LogicException Always.
+	 */
+	public function __clone() {
+		throw new LogicException( 'WP_Secret cannot be cloned.' );
+	}
+
+	/**
+	 * Zeroes the plaintext and removes it from the vault.
+	 *
+	 * @since 7.2.0
+	 */
+	public function __destruct() {
+		$id = spl_object_id( $this );
+
+		if ( isset( self::$vault[ $id ] ) ) {
+			wp_secrets_memzero( self::$vault[ $id ] );
+			unset( self::$vault[ $id ] );
+		}
+	}
+}
diff --git a/wp-includes/secrets/class-wp-secrets-broken-keyring.php b/wp-includes/secrets/class-wp-secrets-broken-keyring.php
new file mode 100644
index 0000000000..85b51f9e05
--- /dev/null
+++ b/wp-includes/secrets/class-wp-secrets-broken-keyring.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Secrets API: WP_Secrets_Broken_Keyring class
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * Stands in for the keyring when a drop-in leaves an invalid value behind.
+ *
+ * Installed when a secrets.php drop-in exists but $GLOBALS['wp_secrets_keyring'] does
+ * not hold a WP_Secrets_Keyring.
+ *
+ * Same reasoning as WP_Secrets_Broken_Store: the drop-in's presence signals the
+ * operator wants a keyring other than the default, so silently falling back to
+ * WP_Secrets_Config_Key_Provider would wrap the root key under a key the operator
+ * did not choose. Every operation fails closed instead.
+ *
+ * @since 7.2.0
+ */
+final class WP_Secrets_Broken_Keyring implements WP_Secrets_Keyring {
+
+	/**
+	 * Always fails closed.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $key_material Ignored.
+	 * @return WP_Error
+	 */
+	public function wrap( string $key_material ): WP_Error {
+		return $this->error();
+	}
+
+	/**
+	 * Always fails closed.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $wrapped Ignored.
+	 * @return WP_Error
+	 */
+	public function unwrap( string $wrapped ): WP_Error {
+		return $this->error();
+	}
+
+	/**
+	 * Describes the broken state, for Site Health.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 */
+	public function get_key_source(): string {
+		return __( 'broken secrets.php drop-in' );
+	}
+
+	/**
+	 * Builds the error every method returns.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return WP_Error
+	 */
+	private function error(): WP_Error {
+		return new WP_Error(
+			WP_SECRETS_ERROR_KEY_UNAVAILABLE,
+			__( 'The secrets.php drop-in did not provide a usable keyring.' )
+		);
+	}
+}
diff --git a/wp-includes/secrets/class-wp-secrets-broken-provider.php b/wp-includes/secrets/class-wp-secrets-broken-provider.php
new file mode 100644
index 0000000000..8c429c55e0
--- /dev/null
+++ b/wp-includes/secrets/class-wp-secrets-broken-provider.php
@@ -0,0 +1,149 @@
+<?php
+/**
+ * Secrets API: WP_Secrets_Broken_Provider class
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * The provider installed when a secrets.php drop-in did not load correctly.
+ *
+ * Every operation returns WP_Error. It exists so that a broken drop-in cannot be
+ * mistaken for a working site with no secrets in it yet – which is the failure
+ * mode that turns "my credential backend is misconfigured" into "my credentials
+ * appear to have been deleted," and is precisely what this API's three-state return
+ * exists to prevent.
+ *
+ * Deliberately not a fallback to the default provider. Reverting to local storage
+ * because a host's KMS drop-in failed would quietly downgrade a site's protection
+ * at the exact moment nobody is watching.
+ *
+ * @since 7.2.0
+ */
+final class WP_Secrets_Broken_Provider implements WP_Secrets_Provider {
+
+	/**
+	 * The single error every operation reports.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return WP_Error
+	 */
+	private function error(): WP_Error {
+		return new WP_Error(
+			WP_SECRETS_ERROR_STORE_UNAVAILABLE,
+			__( 'The secrets.php drop-in did not load correctly, so no secret can be read or written. Fix or remove the drop-in.' )
+		);
+	}
+
+	/**
+	 * Always an error.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's name.
+	 * @param string $version A WP_Secret_Version constant.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return WP_Error
+	 *
+	 * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version
+	 */
+	public function get( string $name, string $version, bool $network = false ): WP_Error {
+		return $this->error();
+	}
+
+	/**
+	 * Always an error.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string      $name           The secret's name.
+	 * @param string      $value          The plaintext value.
+	 * @param bool        $network        Whether this is a network-scope secret.
+	 * @param bool        $needs_rotation Ignored.
+	 * @param string|null $action         Ignored.
+	 * @return WP_Error
+	 */
+	public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ): WP_Error {
+		return $this->error();
+	}
+
+	/**
+	 * Always an error.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's name.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return WP_Error
+	 */
+	public function delete( string $name, bool $network = false ): WP_Error {
+		return $this->error();
+	}
+
+	/**
+	 * Always an error.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's name.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return WP_Error
+	 */
+	public function retire_previous( string $name, bool $network = false ): WP_Error {
+		return $this->error();
+	}
+
+	/**
+	 * Always an error.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name_prefix Restrict to names beginning with this prefix.
+	 * @param bool   $network     Whether to list network-scope secrets.
+	 * @return WP_Error
+	 */
+	public function list_secrets( string $name_prefix = '', bool $network = false ): WP_Error {
+		return $this->error();
+	}
+
+	/**
+	 * Says plainly that the drop-in is the problem.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 */
+	public function get_label(): string {
+		return __( 'Unavailable: the secrets.php drop-in did not load correctly' );
+	}
+
+	/**
+	 * Reports the provider boundary.
+	 *
+	 * Whatever was meant to protect these secrets is not WordPress, and is not
+	 * working.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 *
+	 * @phpstan-return self::BOUNDARY_*
+	 */
+	public function get_protection_boundary(): string {
+		return self::BOUNDARY_PROVIDER;
+	}
+
+	/**
+	 * Nothing is writable while the drop-in is broken.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return bool
+	 */
+	public function is_writable(): bool {
+		return false;
+	}
+}
diff --git a/wp-includes/secrets/class-wp-secrets-broken-store.php b/wp-includes/secrets/class-wp-secrets-broken-store.php
new file mode 100644
index 0000000000..31e64d8e5c
--- /dev/null
+++ b/wp-includes/secrets/class-wp-secrets-broken-store.php
@@ -0,0 +1,92 @@
+<?php
+/**
+ * Secrets API: WP_Secrets_Broken_Store class
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * Stands in for the store when a drop-in leaves an invalid value behind.
+ *
+ * Installed when a secrets.php drop-in exists but $GLOBALS['wp_secrets_store'] does
+ * not hold a WP_Secrets_Store.
+ *
+ * The drop-in's presence signals the operator wants storage other than the
+ * default – falling back to WP_Secrets_Option_Store here would silently write
+ * secrets to local options against that intent, which is exactly the silent
+ * downgrade to local storage this API refuses to make. Every operation fails
+ * closed instead.
+ *
+ * @since 7.2.0
+ */
+final class WP_Secrets_Broken_Store implements WP_Secrets_Store {
+
+	/**
+	 * Always fails closed.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    Ignored.
+	 * @param bool   $network Ignored.
+	 * @return WP_Error
+	 */
+	public function get( string $name, bool $network = false ): WP_Error {
+		return $this->error();
+	}
+
+	/**
+	 * Always fails closed.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string       $name    Ignored.
+	 * @param array<mixed> $record  Ignored.
+	 * @param bool         $network Ignored.
+	 * @return WP_Error
+	 */
+	public function set( string $name, array $record, bool $network = false ): WP_Error {
+		return $this->error();
+	}
+
+	/**
+	 * Always fails closed.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    Ignored.
+	 * @param bool   $network Ignored.
+	 * @return WP_Error
+	 */
+	public function delete( string $name, bool $network = false ): WP_Error {
+		return $this->error();
+	}
+
+	/**
+	 * Always fails closed.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param bool $network Ignored.
+	 * @return WP_Error
+	 */
+	public function list_names( bool $network = false ): WP_Error {
+		return $this->error();
+	}
+
+
+	/**
+	 * Builds the error every method returns.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return WP_Error
+	 */
+	private function error(): WP_Error {
+		return new WP_Error(
+			WP_SECRETS_ERROR_STORE_UNAVAILABLE,
+			__( 'The secrets.php drop-in did not provide a usable store.' )
+		);
+	}
+}
diff --git a/wp-includes/secrets/class-wp-secrets-cipher.php b/wp-includes/secrets/class-wp-secrets-cipher.php
new file mode 100644
index 0000000000..4dc631597c
--- /dev/null
+++ b/wp-includes/secrets/class-wp-secrets-cipher.php
@@ -0,0 +1,409 @@
+<?php
+/**
+ * Secrets API: WP_Secrets_Cipher class
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * Encrypts and decrypts a single record slot.
+ *
+ * A slot holds a per-secret data key wrapped by a master key, plus a value encrypted
+ * under that data key.
+ *
+ * This class does not know where a master key comes from (that is
+ * WP_Secrets_Key_Manager's job), does not know about the 'v'/current/previous record
+ * envelope (that is assembled by the functions in secrets.php), and does not set
+ * 'created' or 'needs_rotation' (those are facts about a write, not about the
+ * cryptography). It knows exactly one thing: given a 32-byte master key and a single
+ * slot's worth of material, encrypt or decrypt it correctly.
+ *
+ * @since 7.2.0
+ */
+final class WP_Secrets_Cipher {
+
+	/**
+	 * AAD purpose tag for a wrapped data key.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const AAD_DATA_KEY = 'wp-secrets-data-key-v1';
+
+	/**
+	 * AAD purpose tag for an encrypted value.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const AAD_VALUE = 'wp-secrets-value-v1';
+
+	/**
+	 * KDF context used to derive the fingerprint key from a master key.
+	 *
+	 * Exactly 8 bytes, as sodium_crypto_kdf_derive_from_key() requires.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const FINGERPRINT_KDF_CONTEXT = 'wpsecfpr';
+
+	/**
+	 * Encrypts a plaintext into a single record slot.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $master_key 32-byte master key for this scope.
+	 * @param string $scope      'site' or 'network'.
+	 * @param int    $site_id    Binds the AAD to a specific blog for site scope; use
+	 *                           0 for network scope, which is not bound to any one blog.
+	 * @param string $name       The secret's namespaced name.
+	 * @param string $slot       A WP_Secret_Version constant.
+	 * @param string $plaintext  The value to encrypt.
+	 * @return array|WP_Error Slot array with keys 'dk', 'dk_nonce', 'ct', 'nonce',
+	 *                        'fingerprint' on success. WP_Error on failure.
+	 *
+	 * @phpstan-return array{dk: string, dk_nonce: string, ct: string, nonce: string, fingerprint: string}|WP_Error
+	 * @phpstan-param 'site'|'network' $scope
+	 * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot
+	 */
+	public function encrypt_value( string $master_key, string $scope, int $site_id, string $name, string $slot, $plaintext ) {
+		$check = $this->validate_common( $master_key, $scope, $site_id, $name, $slot );
+
+		if ( is_wp_error( $check ) ) {
+			return $check;
+		}
+
+		if ( ! is_string( $plaintext ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_INVALID_VALUE,
+				__( 'Secret values must be strings.' )
+			);
+		}
+
+		if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE,
+				__( 'No libsodium implementation is available.' )
+			);
+		}
+
+		$fingerprint = $this->fingerprint( $master_key, $plaintext );
+
+		if ( is_wp_error( $fingerprint ) ) {
+			return $fingerprint;
+		}
+
+		$data_key = random_bytes( SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES );
+
+		$dk_nonce   = random_bytes( SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES );
+		$wrapped_dk = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
+			$data_key,
+			$this->build_aad( self::AAD_DATA_KEY, $scope, $site_id, $name, $slot ),
+			$dk_nonce,
+			$master_key
+		);
+
+		$value_nonce = random_bytes( SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES );
+		$ciphertext  = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
+			$plaintext,
+			$this->build_aad( self::AAD_VALUE, $scope, $site_id, $name, $slot ),
+			$value_nonce,
+			$data_key
+		);
+
+		wp_secrets_memzero( $data_key );
+
+		return array(
+			'dk'          => base64_encode( $wrapped_dk ),
+			'dk_nonce'    => base64_encode( $dk_nonce ),
+			'ct'          => base64_encode( $ciphertext ),
+			'nonce'       => base64_encode( $value_nonce ),
+			'fingerprint' => $fingerprint,
+		);
+	}
+
+	/**
+	 * Decrypts a single record slot back to its plaintext.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $master_key 32-byte master key for this scope.
+	 * @param string $scope      'site' or 'network'. Must match what encrypt_value()
+	 *                           was called with, or decryption fails.
+	 * @param int    $site_id    Must match what encrypt_value() was called with.
+	 * @param string $name       Must match what encrypt_value() was called with.
+	 * @param string $slot       Must match what encrypt_value() was called with.
+	 * @param mixed  $record     The slot array previously returned by encrypt_value().
+	 * @return string|WP_Error Plaintext on success. WP_Error on failure.
+	 *
+	 * @phpstan-param 'site'|'network' $scope
+	 * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot
+	 */
+	public function decrypt_value( string $master_key, string $scope, int $site_id, string $name, string $slot, $record ) {
+		$check = $this->validate_common( $master_key, $scope, $site_id, $name, $slot );
+
+		if ( is_wp_error( $check ) ) {
+			return $check;
+		}
+
+		$decoded = $this->decode_record_fields( $record );
+
+		if ( is_wp_error( $decoded ) ) {
+			return $decoded;
+		}
+
+		if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_decrypt' ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE,
+				__( 'No libsodium implementation is available.' )
+			);
+		}
+
+		$data_key = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
+			$decoded['dk'],
+			$this->build_aad( self::AAD_DATA_KEY, $scope, $site_id, $name, $slot ),
+			$decoded['dk_nonce'],
+			$master_key
+		);
+
+		if ( ! is_string( $data_key ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_DECRYPTION_FAILED,
+				__( "The secret's data key could not be decrypted." )
+			);
+		}
+
+		$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
+			$decoded['ct'],
+			$this->build_aad( self::AAD_VALUE, $scope, $site_id, $name, $slot ),
+			$decoded['nonce'],
+			$data_key
+		);
+
+		wp_secrets_memzero( $data_key );
+
+		if ( ! is_string( $plaintext ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_DECRYPTION_FAILED,
+				__( 'The secret value could not be decrypted.' )
+			);
+		}
+
+		return $plaintext;
+	}
+
+	/**
+	 * Computes the keyed fingerprint of a plaintext under a master key.
+	 *
+	 * Keyed so a fingerprint is not a cross-site rainbow-table oracle: the same
+	 * plaintext fingerprints differently under a different master key. Callers
+	 * verifying a value against a previously stored fingerprint (for example, a
+	 * migration's verify-before-delete step) must recompute this from freshly
+	 * decrypted plaintext and compare – never trust a fingerprint read back from a
+	 * record, which sits outside the AAD and is not authenticated.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $master_key 32-byte master key.
+	 * @param string $plaintext  Value to fingerprint.
+	 * @return string|WP_Error 32-character hex string on success. WP_Error on failure.
+	 */
+	public function fingerprint( string $master_key, $plaintext ) {
+		if ( ! $this->is_valid_master_key( $master_key ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_KEY_UNAVAILABLE,
+				__( 'A 32-byte master key is required.' )
+			);
+		}
+
+		if ( ! is_string( $plaintext ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_INVALID_VALUE,
+				__( 'Secret values must be strings.' )
+			);
+		}
+
+		if ( ! function_exists( 'sodium_crypto_kdf_derive_from_key' ) || ! function_exists( 'sodium_crypto_generichash' ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE,
+				__( 'No libsodium implementation is available.' )
+			);
+		}
+
+		$fingerprint_key = sodium_crypto_kdf_derive_from_key( 32, 1, self::FINGERPRINT_KDF_CONTEXT, $master_key );
+		$fingerprint     = sodium_crypto_generichash( $plaintext, $fingerprint_key, 16 );
+
+		wp_secrets_memzero( $fingerprint_key );
+
+		return bin2hex( $fingerprint );
+	}
+
+	/**
+	 * Builds the AAD that binds a ciphertext to its full context.
+	 *
+	 * Binding covers purpose, scope, site, name, and slot, so that a record can never
+	 * be replayed under any of them.
+	 *
+	 * Centralized in one place rather than inlined at each call site, and only ever
+	 * called after validate_common() has confirmed $name passes
+	 * wp_secrets_validate_name() – the validated character set contains no '|', which
+	 * is what makes this delimiter safe to join on.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $purpose One of the AAD_* class constants.
+	 * @param string $scope   'site' or 'network'.
+	 * @param int    $site_id Blog id for site scope, 0 for network scope.
+	 * @param string $name    Validated secret name.
+	 * @param string $slot    A WP_Secret_Version constant.
+	 * @return string
+	 *
+	 * @phpstan-param 'site'|'network' $scope
+	 * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot
+	 */
+	private function build_aad( string $purpose, string $scope, int $site_id, string $name, string $slot ): string {
+		return sprintf( '%s|%s|%d|%s|%s', $purpose, $scope, $site_id, $name, $slot );
+	}
+
+	/**
+	 * Validates the parameters shared by encrypt_value() and decrypt_value().
+	 *
+	 * $scope, $site_id, and $slot are never influenced by external input – every
+	 * call site in this API supplies them internally – so a bad value here means
+	 * the calling code is wrong, not that a runtime condition failed. That still
+	 * reports as a WP_Error rather than an exception: WordPress functions return
+	 * WP_Error or false, they do not throw. The _doing_it_wrong() notice is what
+	 * makes the distinction visible during development. $name and $master_key
+	 * legitimately vary at runtime (a plugin author's typo, an unavailable key
+	 * backend) and have always reported the same way.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $master_key Candidate master key.
+	 * @param string $scope      Candidate scope.
+	 * @param int    $site_id    Candidate site id.
+	 * @param string $name       Candidate secret name.
+	 * @param string $slot       Candidate slot.
+	 * @return true|WP_Error
+	 *
+	 * @phpstan-param 'site'|'network' $scope
+	 * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot
+	 */
+	private function validate_common( string $master_key, string $scope, int $site_id, string $name, string $slot ) {
+		if ( ! in_array( $scope, array( 'site', 'network' ), true ) ) {
+			return $this->invalid_argument( __( 'The scope must be "site" or "network".' ) );
+		}
+
+		if ( $site_id < 0 ) {
+			return $this->invalid_argument( __( 'The site id must be a non-negative integer.' ) );
+		}
+
+		if ( ! in_array( $slot, array( WP_Secret_Version::CURRENT, WP_Secret_Version::PREVIOUS ), true ) ) {
+			return $this->invalid_argument( __( 'The slot must be a WP_Secret_Version constant.' ) );
+		}
+
+		if ( ! $this->is_valid_master_key( $master_key ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_KEY_UNAVAILABLE,
+				__( 'A 32-byte master key is required.' )
+			);
+		}
+
+		$name_check = wp_secrets_validate_name( $name );
+
+		if ( is_wp_error( $name_check ) ) {
+			return $name_check;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Reports a caller error the way WordPress does.
+	 *
+	 * Emits a _doing_it_wrong() notice for the developer, and returns a WP_Error the
+	 * caller can act on.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $message What the caller got wrong.
+	 * @return WP_Error
+	 */
+	private function invalid_argument( string $message ): WP_Error {
+		_doing_it_wrong( __CLASS__ . '::encrypt_value()/decrypt_value()', $message, '7.2.0' );
+
+		return new WP_Error( WP_SECRETS_ERROR_INVALID_ARGUMENT, $message );
+	}
+
+	/**
+	 * Whether a candidate value is usable as a 32-byte master key.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $master_key Candidate master key.
+	 * @return bool
+	 */
+	private function is_valid_master_key( string $master_key ): bool {
+		return SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES === strlen( $master_key );
+	}
+
+	/**
+	 * Validates and base64-decodes the four crypto fields of a record slot.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param mixed $record Candidate slot array.
+	 * @return array<string, string>|WP_Error Decoded ('dk', 'dk_nonce', 'ct', 'nonce') on success.
+	 */
+	private function decode_record_fields( $record ) {
+		if ( ! is_array( $record ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_RECORD_MALFORMED,
+				__( 'Secret record slot must be an array.' )
+			);
+		}
+
+		$decoded = array();
+
+		foreach ( array( 'dk', 'dk_nonce', 'ct', 'nonce' ) as $field ) {
+			if ( ! isset( $record[ $field ] ) || ! is_string( $record[ $field ] ) ) {
+				return new WP_Error(
+					WP_SECRETS_ERROR_RECORD_MALFORMED,
+					sprintf(
+						/* translators: %s: Record field name. */
+						__( 'Secret record slot is missing the "%s" field.' ),
+						$field
+					)
+				);
+			}
+
+			$value = base64_decode( $record[ $field ], true );
+
+			if ( false === $value ) {
+				return new WP_Error(
+					WP_SECRETS_ERROR_RECORD_MALFORMED,
+					sprintf(
+						/* translators: %s: Record field name. */
+						__( 'Secret record slot field "%s" is not valid base64.' ),
+						$field
+					)
+				);
+			}
+
+			$decoded[ $field ] = $value;
+		}
+
+		if ( SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES !== strlen( $decoded['dk_nonce'] )
+			|| SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES !== strlen( $decoded['nonce'] ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_RECORD_MALFORMED,
+				__( 'Secret record slot has a malformed nonce.' )
+			);
+		}
+
+		return $decoded;
+	}
+}
diff --git a/wp-includes/secrets/class-wp-secrets-config-key-provider.php b/wp-includes/secrets/class-wp-secrets-config-key-provider.php
new file mode 100644
index 0000000000..7fbd208818
--- /dev/null
+++ b/wp-includes/secrets/class-wp-secrets-config-key-provider.php
@@ -0,0 +1,311 @@
+<?php
+/**
+ * Secrets API: WP_Secrets_Config_Key_Provider class
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * The default keyring.
+ *
+ * Derives a site key from wp-config.php and uses it to wrap and unwrap the root key
+ * with authenticated encryption.
+ *
+ * Site key derivation, in priority order:
+ *
+ * 1. WP_SECRETS_KEY is defined and base64-decodes (strictly) to exactly 32 bytes:
+ *    those decoded bytes are used raw. This is the documented, recommended form;
+ *    `wp secret generate-key` emits it.
+ * 2. WP_SECRETS_KEY is defined in any other shape: the literal constant string is
+ *    hashed with a keyed BLAKE2b to 32 bytes. This is the legacy interpretation –
+ *    sites arriving from a prior plugin with a constant of arbitrary shape are not
+ *    locked out of their own credentials by a hard failure here.
+ * 3. WP_SECRETS_KEY is undefined: LOGGED_IN_KEY . LOGGED_IN_SALT is hashed the same
+ *    way. Deliberately byte-identical to the legacy interpretation's hashing, not a
+ *    coincidence – it is what makes salt-fallback sites migrate with zero
+ *    credential re-entry.
+ *
+ * WP_SECRETS_KEY_PREVIOUS follows the same three rules and exists only so a site-key
+ * rotation can unwrap under the old key before wrapping under the new one.
+ *
+ * @since 7.2.0
+ */
+final class WP_Secrets_Config_Key_Provider implements WP_Secrets_Keyring {
+
+	/**
+	 * AAD binding the root key's wrapped form to this purpose.
+	 *
+	 * Prevents the wrapped root key from being replayed as some other wrapped value.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const AAD = 'wp-secrets-root-key-v1';
+
+	/**
+	 * The exact text wp-config-sample.php ships for every auth and salt constant.
+	 *
+	 * A site running with this literal value never ran the secret-key generator.
+	 * Treating it as usable would derive a key that any attacker can also derive.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const KNOWN_PLACEHOLDER = 'put your unique phrase here';
+
+	/**
+	 * Whether to derive from WP_SECRETS_KEY_PREVIOUS instead of WP_SECRETS_KEY.
+	 *
+	 * @since 7.2.0
+	 * @var bool
+	 */
+	private $use_previous_key;
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param bool $use_previous_key Derive from WP_SECRETS_KEY_PREVIOUS rather than
+	 *                               WP_SECRETS_KEY. Used only during a site-key
+	 *                               rotation, to unwrap under the outgoing key.
+	 */
+	public function __construct( bool $use_previous_key = false ) {
+		$this->use_previous_key = $use_previous_key;
+	}
+
+	/**
+	 * Wraps raw key material under the derived site key.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $key_material Raw key material to protect.
+	 * @return string|WP_Error
+	 */
+	public function wrap( string $key_material ) {
+		if ( '' === $key_material ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_INVALID_VALUE,
+				__( 'Key material to wrap must not be empty.' )
+			);
+		}
+
+		if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE,
+				__( 'No libsodium implementation is available.' )
+			);
+		}
+
+		$site_key = $this->get_site_key();
+
+		if ( is_wp_error( $site_key ) ) {
+			return $site_key;
+		}
+
+		$nonce      = random_bytes( SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES );
+		$ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( $key_material, self::AAD, $nonce, $site_key );
+
+		wp_secrets_memzero( $site_key );
+
+		return base64_encode( $nonce . $ciphertext );
+	}
+
+	/**
+	 * Unwraps key material previously wrapped by wrap().
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $wrapped An opaque value previously returned by wrap().
+	 * @return string|WP_Error
+	 */
+	public function unwrap( string $wrapped ) {
+		if ( '' === $wrapped ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_KEY_UNAVAILABLE,
+				__( 'Nothing to unwrap.' )
+			);
+		}
+
+		if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_decrypt' ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE,
+				__( 'No libsodium implementation is available.' )
+			);
+		}
+
+		$raw          = base64_decode( $wrapped, true );
+		$nonce_length = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES;
+
+		if ( false === $raw || strlen( $raw ) <= $nonce_length ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_KEY_UNAVAILABLE,
+				__( 'The wrapped key material is malformed.' )
+			);
+		}
+
+		$nonce      = substr( $raw, 0, $nonce_length );
+		$ciphertext = substr( $raw, $nonce_length );
+
+		$site_key = $this->get_site_key();
+
+		if ( is_wp_error( $site_key ) ) {
+			return $site_key;
+		}
+
+		$key_material = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( $ciphertext, self::AAD, $nonce, $site_key );
+
+		wp_secrets_memzero( $site_key );
+
+		if ( ! is_string( $key_material ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_KEY_UNAVAILABLE,
+				__( 'The wrapped key material could not be decrypted with the configured site key.' )
+			);
+		}
+
+		return $key_material;
+	}
+
+	/**
+	 * Describes the active key source, for Site Health.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 */
+	public function get_key_source(): string {
+		if ( defined( 'WP_SECRETS_KEY' ) ) {
+			return $this->is_canonical_base64_32( WP_SECRETS_KEY )
+				? 'WP_SECRETS_KEY (base64-encoded 32 bytes)'
+				: 'WP_SECRETS_KEY (legacy interpretation, hashed as an opaque string)';
+		}
+
+		return 'derived from LOGGED_IN_KEY and LOGGED_IN_SALT';
+	}
+
+	/**
+	 * Derives the 32-byte site key per the priority rules documented on this class.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string|WP_Error
+	 */
+	private function get_site_key() {
+		$constant_name = $this->use_previous_key ? 'WP_SECRETS_KEY_PREVIOUS' : 'WP_SECRETS_KEY';
+
+		if ( defined( $constant_name ) ) {
+			$raw_constant = constant( $constant_name );
+
+			if ( ! is_string( $raw_constant ) || '' === $raw_constant ) {
+				return new WP_Error(
+					WP_SECRETS_ERROR_KEY_UNAVAILABLE,
+					sprintf(
+						/* translators: %s: PHP constant name. */
+						__( '%s is defined but is not a usable string.' ),
+						$constant_name
+					)
+				);
+			}
+
+			$decoded = $this->decode_canonical_base64_32( $raw_constant );
+
+			if ( null !== $decoded ) {
+				return $decoded;
+			}
+
+			return sodium_crypto_generichash( $raw_constant, '', SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES );
+		}
+
+		if ( $this->use_previous_key ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_KEY_UNAVAILABLE,
+				__( 'WP_SECRETS_KEY_PREVIOUS is not defined.' )
+			);
+		}
+
+		$logged_in_key  = defined( 'LOGGED_IN_KEY' ) ? LOGGED_IN_KEY : null;
+		$logged_in_salt = defined( 'LOGGED_IN_SALT' ) ? LOGGED_IN_SALT : null;
+
+		if ( ! $this->are_usable_salt_values( $logged_in_key, $logged_in_salt ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_KEY_UNAVAILABLE,
+				__( 'WP_SECRETS_KEY is not defined, and LOGGED_IN_KEY/LOGGED_IN_SALT are not usable (undefined, empty, or left at the wp-config-sample.php placeholder).' )
+			);
+		}
+
+		return sodium_crypto_generichash( $logged_in_key . $logged_in_salt, '', SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES );
+	}
+
+	/**
+	 * Whether a pair of candidate salt values are usable as key material.
+	 *
+	 * Both must be non-empty strings, and neither may be the literal
+	 * wp-config-sample.php placeholder – which every unconfigured install shares,
+	 * making it a publicly known value rather than a secret.
+	 *
+	 * Takes explicit arguments rather than reading LOGGED_IN_KEY/LOGGED_IN_SALT
+	 * directly so the decision logic is testable without touching real constants,
+	 * which – once WordPress has bootstrapped once in a process – can never be
+	 * redefined to a different value for a test.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param mixed $logged_in_key  Candidate value of LOGGED_IN_KEY, or null if undefined.
+	 * @param mixed $logged_in_salt Candidate value of LOGGED_IN_SALT, or null if undefined.
+	 * @return bool
+	 *
+	 * @phpstan-assert-if-true non-empty-string $logged_in_key
+	 * @phpstan-assert-if-true non-empty-string $logged_in_salt
+	 */
+	private function are_usable_salt_values( $logged_in_key, $logged_in_salt ): bool {
+		foreach ( array( $logged_in_key, $logged_in_salt ) as $value ) {
+			if ( ! is_string( $value ) || '' === $value || self::KNOWN_PLACEHOLDER === $value ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Whether $value is the canonical base64 encoding of exactly 32 bytes.
+	 *
+	 * Uses strict-mode decoding plus a re-encode comparison, rather than a bare
+	 * base64_decode(), so that a malformed or non-canonically-padded constant falls
+	 * through to the legacy (hashed) interpretation instead of being silently
+	 * accepted as 32 bytes of something else.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param mixed $value Candidate value.
+	 * @return bool
+	 */
+	private function is_canonical_base64_32( $value ): bool {
+		return null !== $this->decode_canonical_base64_32( $value );
+	}
+
+	/**
+	 * Decodes $value if it is the canonical base64 encoding of exactly 32 bytes.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param mixed $value Candidate value.
+	 * @return string|null The 32 decoded bytes, or null if $value is anything else.
+	 */
+	private function decode_canonical_base64_32( $value ): ?string {
+		if ( ! is_string( $value ) ) {
+			return null;
+		}
+
+		$decoded = base64_decode( $value, true );
+
+		if ( false === $decoded || SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES !== strlen( $decoded ) ) {
+			return null;
+		}
+
+		return base64_encode( $decoded ) === $value ? $decoded : null;
+	}
+}
diff --git a/wp-includes/secrets/class-wp-secrets-key-manager.php b/wp-includes/secrets/class-wp-secrets-key-manager.php
new file mode 100644
index 0000000000..1cd92a3837
--- /dev/null
+++ b/wp-includes/secrets/class-wp-secrets-key-manager.php
@@ -0,0 +1,382 @@
+<?php
+/**
+ * Secrets API: WP_Secrets_Key_Manager class
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * Manages the root key and derives per-scope master keys from it.
+ *
+ * Owns the root key's generation, storage, and rotation.
+ *
+ * There is exactly one root key per install: 32 random bytes, generated once, wrapped
+ * by the active WP_Secrets_Keyring, and stored via update_site_option() (which is a
+ * plain option on single-site, and network-wide on multisite). It is the only value a
+ * site-key rotation ever re-wraps, on a single site or on a 500-site network.
+ *
+ * Master keys are derived from the root key on demand and never stored:
+ *
+ * - Site scope, blog N: sodium_crypto_kdf_derive_from_key( 32, N, 'wpsecsit', $root ).
+ *   Distinct per blog, which shared network-wide salts otherwise cannot provide.
+ * - Network scope: sodium_crypto_kdf_derive_from_key( 32, 0, 'wpsecnet', $root ).
+ *   Subkey id 0 is reserved for this (blog ids start at 1) and the context string
+ *   differs from the site path, so there is no collision between the two. Identical
+ *   on every blog, so a network secret written on one blog reads on every other.
+ *
+ * One unwrapped copy of the root key lives in this object for the rest of the
+ * request, in memory only, never in the object cache. It is replaced whenever the
+ * stored wrapped value changes – a rotation, a re-wrap, a restore – so it is never
+ * stale. Callers of get_root_key() still receive a copy and must zero it themselves;
+ * this object's own copy is not theirs to zero. The practical effect: a remote
+ * keyring (a KMS or HSM call) is invoked once per request, not once per secret.
+ *
+ * @since 7.2.0
+ */
+final class WP_Secrets_Key_Manager {
+
+	/**
+	 * Option name the wrapped root key is stored under.
+	 *
+	 * Always accessed through the *_site_option() functions directly, never through a
+	 * WP_Secrets_Store. Swapping the pluggable secret store therefore never relocates
+	 * the root key, and swapping the keyring never affects where ciphertext lives.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const ROOT_KEY_OPTION = '_wp_secrets_root_key';
+
+	/**
+	 * KDF context for deriving a site-scope master key. Exactly 8 bytes.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const SITE_KDF_CONTEXT = 'wpsecsit';
+
+	/**
+	 * KDF context for deriving the network-scope master key. Exactly 8 bytes.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const NETWORK_KDF_CONTEXT = 'wpsecnet';
+
+	/**
+	 * Reserved KDF subkey id for network scope.
+	 *
+	 * Blog ids start at 1, so this can never collide with a site-scope subkey id.
+	 *
+	 * @since 7.2.0
+	 * @var int
+	 */
+	const NETWORK_SUBKEY_ID = 0;
+
+	/**
+	 * The keyring used to wrap and unwrap the root key.
+	 *
+	 * @since 7.2.0
+	 * @var WP_Secrets_Keyring
+	 */
+	private $keyring;
+
+	/**
+	 * The unwrapped root key currently cached for this request, or null if nothing
+	 * has been unwrapped yet.
+	 *
+	 * @since 7.2.0
+	 * @var string|null
+	 */
+	private $cached_root_key = null;
+
+	/**
+	 * The wrapped value $cached_root_key was unwrapped from, or null if nothing has
+	 * been unwrapped yet. Used to detect that the stored wrapped value changed
+	 * underneath this object (a rotation, a re-wrap, a restore) so the cache is not
+	 * served stale.
+	 *
+	 * @since 7.2.0
+	 * @var string|null
+	 */
+	private $cached_wrapped = null;
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param WP_Secrets_Keyring|null $keyring Keyring to use. Defaults to the
+	 *                                         built-in config-based provider.
+	 */
+	public function __construct( ?WP_Secrets_Keyring $keyring = null ) {
+		$this->keyring = $keyring ? $keyring : new WP_Secrets_Config_Key_Provider();
+	}
+
+	/**
+	 * Returns the keyring in use.
+	 *
+	 * Used by Site Health and by `wp secret dropin` and `wp secret health`, which all
+	 * need to describe the active keyring without duplicating the logic that resolves
+	 * it.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return WP_Secrets_Keyring
+	 */
+	public function get_keyring(): WP_Secrets_Keyring {
+		return $this->keyring;
+	}
+
+	/**
+	 * Derives a scope's master key from the root key.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string   $scope   'site' or 'network'.
+	 * @param int|null $site_id Blog id for site scope. Defaults to the current blog.
+	 *                          Ignored for network scope.
+	 * @return string|WP_Error 32-byte master key on success. WP_Error on failure,
+	 *                         including when a caller passes an invalid scope or
+	 *                         site id – see WP_Secrets_Cipher::validate_common()
+	 *                         for why that is a WP_Error and not an exception.
+	 *
+	 * @phpstan-param 'site'|'network' $scope
+	 */
+	public function get_master_key( string $scope, ?int $site_id = null ) {
+		if ( ! in_array( $scope, array( 'site', 'network' ), true ) ) {
+			$message = __( 'The scope must be "site" or "network".' );
+
+			_doing_it_wrong( __METHOD__, $message, '7.2.0' );
+
+			return new WP_Error( WP_SECRETS_ERROR_INVALID_ARGUMENT, $message );
+		}
+
+		if ( 'network' === $scope ) {
+			$subkey_id = self::NETWORK_SUBKEY_ID;
+			$context   = self::NETWORK_KDF_CONTEXT;
+		} else {
+			$subkey_id = null === $site_id ? get_current_blog_id() : $site_id;
+
+			if ( $subkey_id < 1 ) {
+				$message = __( 'The site id must be a positive integer.' );
+
+				_doing_it_wrong( __METHOD__, $message, '7.2.0' );
+
+				return new WP_Error( WP_SECRETS_ERROR_INVALID_ARGUMENT, $message );
+			}
+
+			$context = self::SITE_KDF_CONTEXT;
+		}
+
+		$root_key = $this->get_root_key();
+
+		if ( is_wp_error( $root_key ) ) {
+			return $root_key;
+		}
+
+		if ( ! function_exists( 'sodium_crypto_kdf_derive_from_key' ) ) {
+			wp_secrets_memzero( $root_key );
+
+			return new WP_Error(
+				WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE,
+				__( 'No libsodium implementation is available.' )
+			);
+		}
+
+		$master_key = sodium_crypto_kdf_derive_from_key( 32, $subkey_id, $context, $root_key );
+
+		wp_secrets_memzero( $root_key );
+
+		return $master_key;
+	}
+
+	/**
+	 * Returns the root key, generating and persisting one on first use.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string|WP_Error 32 raw bytes on success. WP_Error on failure.
+	 */
+	public function get_root_key() {
+		$wrapped = $this->get_wrapped_root_key();
+
+		if ( false === $wrapped ) {
+			return $this->generate_root_key();
+		}
+
+		if ( ! is_string( $wrapped ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_RECORD_MALFORMED,
+				__( 'The stored root key is not a string.' )
+			);
+		}
+
+		if ( null !== $this->cached_root_key && $wrapped === $this->cached_wrapped ) {
+			return $this->cached_root_key;
+		}
+
+		$root_key = $this->keyring->unwrap( $wrapped );
+
+		if ( is_string( $root_key ) ) {
+			$this->cached_wrapped  = $wrapped;
+			$this->cached_root_key = $root_key;
+		}
+
+		return $root_key;
+	}
+
+	/**
+	 * Re-wraps the root key under a new keyring, without touching any stored secret.
+	 *
+	 * Every derived master key is unchanged by this: they come from the root key's
+	 * raw bytes, which rotation never alters, only its wrapping. No secret value is
+	 * ever re-encrypted as a result of a site-key rotation.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param WP_Secrets_Keyring $old_keyring Unwraps the current wrapped root key.
+	 * @param WP_Secrets_Keyring $new_keyring Wraps it again for storage.
+	 * @return true|WP_Error
+	 */
+	public function rotate_site_key( WP_Secrets_Keyring $old_keyring, WP_Secrets_Keyring $new_keyring ) {
+		$wrapped = $this->get_wrapped_root_key();
+
+		if ( ! is_string( $wrapped ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_STORE_UNAVAILABLE,
+				__( 'No root key exists to rotate.' )
+			);
+		}
+
+		if ( $old_keyring === $this->keyring && null !== $this->cached_root_key && $wrapped === $this->cached_wrapped ) {
+			$root_key = $this->cached_root_key;
+		} else {
+			$root_key = $old_keyring->unwrap( $wrapped );
+		}
+
+		if ( is_wp_error( $root_key ) ) {
+			return $root_key;
+		}
+
+		$rewrapped = $new_keyring->wrap( $root_key );
+
+		if ( is_wp_error( $rewrapped ) ) {
+			wp_secrets_memzero( $root_key );
+
+			return $rewrapped;
+		}
+
+		/*
+		 * update_site_option() returns false both on genuine failure and when the
+		 * new value equals the old one – a documented ambiguity shared with
+		 * update_option(). In practice this never collides here: wrap() draws a
+		 * fresh random nonce every call, so the re-wrapped value is only equal to
+		 * the old one if this call somehow re-wrapped under the exact same nonce,
+		 * astronomically unlikely.
+		 */
+		if ( ! update_site_option( self::ROOT_KEY_OPTION, $rewrapped ) ) {
+			wp_secrets_memzero( $root_key );
+
+			return new WP_Error(
+				WP_SECRETS_ERROR_STORE_UNAVAILABLE,
+				__( 'Could not store the re-wrapped root key.' )
+			);
+		}
+
+		$this->cached_wrapped  = $rewrapped;
+		$this->cached_root_key = $root_key;
+
+		wp_secrets_memzero( $root_key );
+
+		return true;
+	}
+
+	/**
+	 * Returns the wrapped root key, adopting a pre-conversion copy if needed.
+	 *
+	 * A single site converted to multisite keeps its wrapped root key in its own
+	 * (now main site's) options row: `update_site_option()` only starts writing to
+	 * sitemeta once `is_multisite()` is true, and the conversion process does not
+	 * move existing option rows. Without this, a network's first read would find no
+	 * sitemeta row and mint a brand new root key, stranding every secret written
+	 * before conversion.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return mixed The wrapped root key, or false if none exists anywhere. Anything
+	 *               but a string or false is a corrupt row, which the caller reports.
+	 */
+	private function get_wrapped_root_key() {
+		$wrapped = get_site_option( self::ROOT_KEY_OPTION );
+
+		if ( false !== $wrapped || ! is_multisite() ) {
+			return $wrapped;
+		}
+
+		$main_site_id = get_main_site_id();
+		$legacy       = get_blog_option( $main_site_id, self::ROOT_KEY_OPTION );
+
+		if ( ! is_string( $legacy ) ) {
+			return false;
+		}
+
+		if ( add_site_option( self::ROOT_KEY_OPTION, $legacy ) ) {
+			delete_blog_option( $main_site_id, self::ROOT_KEY_OPTION );
+		}
+
+		// Re-read: handles a concurrent request that already adopted it.
+		return get_site_option( self::ROOT_KEY_OPTION );
+	}
+
+	/**
+	 * Generates a new root key and persists it.
+	 *
+	 * Handles the race where two requests both find no root key at the same time.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string|WP_Error
+	 */
+	private function generate_root_key() {
+		$candidate = random_bytes( 32 );
+
+		$wrapped = $this->keyring->wrap( $candidate );
+
+		if ( is_wp_error( $wrapped ) ) {
+			wp_secrets_memzero( $candidate );
+
+			return $wrapped;
+		}
+
+		if ( add_site_option( self::ROOT_KEY_OPTION, $wrapped ) ) {
+			$this->cached_wrapped  = $wrapped;
+			$this->cached_root_key = $candidate;
+
+			return $candidate;
+		}
+
+		// Lost the race: another request created one first. Use theirs, not ours.
+		wp_secrets_memzero( $candidate );
+
+		$existing = get_site_option( self::ROOT_KEY_OPTION );
+
+		if ( ! is_string( $existing ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_STORE_UNAVAILABLE,
+				__( 'Could not read the root key after losing the creation race.' )
+			);
+		}
+
+		$root_key = $this->keyring->unwrap( $existing );
+
+		if ( is_string( $root_key ) ) {
+			$this->cached_wrapped  = $existing;
+			$this->cached_root_key = $root_key;
+		}
+
+		return $root_key;
+	}
+}
diff --git a/wp-includes/secrets/class-wp-secrets-libsodium-provider.php b/wp-includes/secrets/class-wp-secrets-libsodium-provider.php
new file mode 100644
index 0000000000..d7dc205cf9
--- /dev/null
+++ b/wp-includes/secrets/class-wp-secrets-libsodium-provider.php
@@ -0,0 +1,688 @@
+<?php
+/**
+ * Secrets API: WP_Secrets_Libsodium_Provider class
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * The provider WordPress ships.
+ *
+ * Uses libsodium envelope encryption and stores ciphertext in the options tables.
+ *
+ * This is the default rather than a privileged case. Everything it does is expressed
+ * through WP_Secrets_Provider, the same interface a platform implements, so a KMS-
+ * or HSM-backed provider is a peer rather than an exception carved into the side of
+ * this one.
+ *
+ * It is assembled from two smaller pieces, and that is deliberate: a host who wants
+ * their own key custody but is happy with WordPress's storage swaps only the
+ * keyring and keeps everything else, and the inverse works too. Coupling them would
+ * force an all-or-nothing decision most sites cannot make.
+ *
+ * - WP_Secrets_Store   – where the ciphertext records live.
+ * - WP_Secrets_Keyring – what wraps the root key everything else derives from.
+ *
+ * Neither is handed a plaintext, because for this provider the encryption boundary
+ * genuinely is inside WordPress. That is a property of *this* implementation rather
+ * than a rule imposed on every provider; see WP_Secrets_Provider for why the
+ * distinction matters.
+ *
+ * @since 7.2.0
+ */
+final class WP_Secrets_Libsodium_Provider implements WP_Secrets_Provider {
+
+	/**
+	 * Where ciphertext records live.
+	 *
+	 * @since 7.2.0
+	 * @var WP_Secrets_Store
+	 */
+	private $store;
+
+	/**
+	 * Derives the per-scope master keys this provider encrypts under.
+	 *
+	 * @since 7.2.0
+	 * @var WP_Secrets_Key_Manager
+	 */
+	private $key_manager;
+
+	/**
+	 * Composes the provider from its two replaceable halves.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param WP_Secrets_Store       $store       Where ciphertext records live.
+	 * @param WP_Secrets_Key_Manager $key_manager Derives per-scope master keys.
+	 */
+	public function __construct( WP_Secrets_Store $store, WP_Secrets_Key_Manager $key_manager ) {
+		$this->store       = $store;
+		$this->key_manager = $key_manager;
+	}
+
+	/**
+	 * A description of what is protecting these secrets, for Site Health.
+	 *
+	 * Reports the keyring's own description rather than a fixed string: "libsodium"
+	 * is only half the answer, and which key source is in use is the half an
+	 * operator cannot otherwise see.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 */
+	public function get_label(): string {
+		$keyring = $this->key_manager->get_keyring();
+
+		return sprintf(
+			/* translators: %s: Human-readable description of the key source. */
+			__( 'WordPress (libsodium), key source: %s' ),
+			$keyring->get_key_source()
+		);
+	}
+
+	/**
+	 * Protection happens inside WordPress for this provider.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 *
+	 * @phpstan-return self::BOUNDARY_*
+	 */
+	public function get_protection_boundary(): string {
+		return self::BOUNDARY_WORDPRESS;
+	}
+
+	/**
+	 * Always true.
+	 *
+	 * A store that cannot accept a write reports that from set() itself. There is no
+	 * separate capability flag to consult, and the shipped store accepts writes
+	 * unconditionally.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return bool
+	 */
+	public function is_writable(): bool {
+		return true;
+	}
+	/**
+	 * Validates the shape of a record read back from a store.
+	 *
+	 * Runs before any decryption is attempted.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param mixed $record Candidate record.
+	 * @return true|WP_Error
+	 */
+	private function validate_record_shape( $record ) {
+		if ( ! is_array( $record ) || ! array_key_exists( 'v', $record ) || ! array_key_exists( 'current', $record ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_RECORD_MALFORMED,
+				__( 'The stored secret record is missing required fields.' )
+			);
+		}
+
+		if ( WP_SECRETS_RECORD_VERSION !== $record['v'] ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_RECORD_UNSUPPORTED_VERSION,
+				sprintf(
+					/* translators: %s: Unrecognized record format version. */
+					__( 'Secret record format version "%s" is not supported.' ),
+					is_scalar( $record['v'] ) ? $record['v'] : gettype( $record['v'] )
+				)
+			);
+		}
+
+		if ( ! is_array( $record['current'] ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_RECORD_MALFORMED,
+				__( 'The stored secret record is missing required fields.' )
+			);
+		}
+
+		return true;
+	}
+
+	/**
+	 * Reads the record a write or delete is about to replace, tolerating corruption.
+	 *
+	 * A write path needs the prior record only to report the old fingerprint on the
+	 * change hook. That is not worth refusing the operation over: if the stored record
+	 * is unreadable, overwriting or deleting it is the only repair an operator has
+	 * through this API, and returning WP_Error here would leave a corrupted secret
+	 * permanently stuck – neither readable, nor fixable, nor removable without editing
+	 * the database by hand.
+	 *
+	 * An unreachable *store* is different, and still aborts: that is an infrastructure
+	 * failure, not a data problem, and continuing would mean writing blind.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param WP_Secrets_Store $store   The active store.
+	 * @param string           $name    The secret's namespaced name.
+	 * @param bool             $network Whether this is a network-scope secret.
+	 * @return array<mixed>|null|WP_Error The prior record, null if absent or unreadable,
+	 *                                    or WP_Error only if the store itself is
+	 *                                    unavailable.
+	 */
+	private function read_prior_record( WP_Secrets_Store $store, string $name, bool $network ) {
+		$existing = $store->get( $name, $network );
+
+		if ( is_wp_error( $existing ) ) {
+			if ( WP_SECRETS_ERROR_STORE_UNAVAILABLE === $existing->get_error_code() ) {
+				return $existing;
+			}
+
+			return null;
+		}
+
+		return $existing;
+	}
+
+	/**
+	 * Extracts the current slot's stored fingerprint from a record, if it has one.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param mixed $record A record previously read from the store, or null.
+	 * @return string The fingerprint, or '' if there is not a usable one.
+	 */
+	private function stored_fingerprint( $record ): string {
+		if ( ! is_array( $record ) || ! isset( $record['current'] ) || ! is_array( $record['current'] ) ) {
+			return '';
+		}
+
+		return $this->slot_fingerprint( $record['current'] );
+	}
+
+	/**
+	 * Extracts a single slot's stored fingerprint, if it has one.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param mixed $slot A slot previously read from the store.
+	 * @return string The fingerprint, or '' if there is not a usable one.
+	 */
+	private function slot_fingerprint( $slot ): string {
+		if ( ! is_array( $slot ) || ! isset( $slot['fingerprint'] ) || ! is_string( $slot['fingerprint'] ) ) {
+			return '';
+		}
+
+		return $slot['fingerprint'];
+	}
+
+	/**
+	 * Re-encrypts an outgoing current slot so it can be stored as the previous slot.
+	 *
+	 * A slot's ciphertext is bound, via AAD, to the exact position it was written to.
+	 * Copying its stored bytes as-is into the other slot would leave it permanently
+	 * undecryptable there: the authentication tag was computed for the slot it came
+	 * from, and every future read asks for it under the other one. Demoting a slot
+	 * means decrypting it under its old binding and re-encrypting the same plaintext
+	 * under the new one.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param WP_Secrets_Cipher $cipher       A cipher instance.
+	 * @param string            $master_key   32-byte master key.
+	 * @param string            $scope        'site' or 'network'.
+	 * @param int               $site_id      Blog id for site scope, 0 for network scope.
+	 * @param string            $name         The secret's namespaced name.
+	 * @param array<mixed>      $current_slot The outgoing current slot, as stored.
+	 * @return array|WP_Error The same value, re-encrypted and bound to
+	 *                        WP_Secret_Version::PREVIOUS. 'created' and
+	 *                        'needs_rotation' carry over unchanged; 'fingerprint' is
+	 *                        recomputed but identical, since the plaintext and master
+	 *                        key are unchanged.
+	 *
+	 * @phpstan-return array{dk: string, dk_nonce: string, ct: string, nonce: string, fingerprint: string, created: int, needs_rotation: bool}|WP_Error
+	 * @phpstan-param 'site'|'network' $scope
+	 */
+	private function demote_slot( WP_Secrets_Cipher $cipher, string $master_key, string $scope, int $site_id, string $name, array $current_slot ) {
+		$plaintext = $cipher->decrypt_value( $master_key, $scope, $site_id, $name, WP_Secret_Version::CURRENT, $current_slot );
+
+		if ( is_wp_error( $plaintext ) ) {
+			return $plaintext;
+		}
+
+		$demoted = $cipher->encrypt_value( $master_key, $scope, $site_id, $name, WP_Secret_Version::PREVIOUS, $plaintext );
+
+		wp_secrets_memzero( $plaintext );
+
+		if ( is_wp_error( $demoted ) ) {
+			return $demoted;
+		}
+
+		$demoted['created']        = isset( $current_slot['created'] ) && is_int( $current_slot['created'] ) ? $current_slot['created'] : time();
+		$demoted['needs_rotation'] = ! empty( $current_slot['needs_rotation'] );
+
+		return $demoted;
+	}
+
+	/**
+	 * Shared implementation behind the three secret-writing functions.
+	 *
+	 * Backs wp_set_secret(), wp_set_network_secret(), and wp_import_option_as_secret(),
+	 * which select their behavior through the last two parameters.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string      $name           The secret's namespaced name.
+	 * @param string      $value          The plaintext value.
+	 * @param bool        $network        Whether this is a network-scope secret.
+	 * @param bool        $needs_rotation Value for the new current slot's
+	 *                                    'needs_rotation' flag. False for an ordinary
+	 *                                    write; wp_import_option_as_secret() passes true,
+	 *                                    since a credential that sat in an option is
+	 *                                    already in backups and re-encrypting does not
+	 *                                    fix that.
+	 * @param string|null $action          When given, used as the $action reported to
+	 *                                     the wp_secret_changed hook instead of the
+	 *                                     usual 'created'/'updated' detection –
+	 *                                     wp_import_option_as_secret() passes 'imported'.
+	 * @return true|WP_Error
+	 */
+	public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ) {
+		$name_check = wp_secrets_validate_name( $name );
+
+		if ( is_wp_error( $name_check ) ) {
+			return $name_check;
+		}
+
+		if ( ! is_string( $value ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_INVALID_VALUE,
+				__( 'Secret values must be strings.' )
+			);
+		}
+
+		$store = $this->store;
+
+		$scope   = $network ? 'network' : 'site';
+		$site_id = $network ? 0 : get_current_blog_id();
+
+		$existing = $this->read_prior_record( $store, $name, $network );
+
+		if ( is_wp_error( $existing ) ) {
+			return $existing;
+		}
+
+		$master_key = $this->key_manager->get_master_key( $scope, $network ? null : $site_id );
+
+		if ( is_wp_error( $master_key ) ) {
+			return $master_key;
+		}
+
+		$cipher   = new WP_Secrets_Cipher();
+		$new_slot = $cipher->encrypt_value( $master_key, $scope, $site_id, $name, WP_Secret_Version::CURRENT, $value );
+
+		if ( is_wp_error( $new_slot ) ) {
+			wp_secrets_memzero( $master_key );
+
+			return $new_slot;
+		}
+
+		$new_slot['created']        = time();
+		$new_slot['needs_rotation'] = $needs_rotation;
+
+		$record = array(
+			'v'       => WP_SECRETS_RECORD_VERSION,
+			'current' => $new_slot,
+		);
+
+		/*
+		 * Demotion: the outgoing current slot becomes the new previous slot. Only
+		 * one previous slot is ever kept – whatever was already in
+		 * $existing['previous'] is never carried forward, which is what makes a
+		 * third write discard the oldest value rather than accumulating history.
+		 *
+		 * This cannot be a plain array copy. A slot's AAD binds its ciphertext to
+		 * the exact position it occupies – current or previous – so a slot moved
+		 * verbatim would still carry an authentication tag computed for 'current'
+		 * while every future read asks for it under 'previous', and would fail to
+		 * decrypt forever. Demoting requires decrypting under the outgoing binding
+		 * and re-encrypting under the incoming one.
+		 *
+		 * If the outgoing current slot cannot be decrypted at all – already
+		 * corrupted, or orphaned by a botched key rotation – it is dropped rather
+		 * than failing this write. The value was already unreadable before this
+		 * call; refusing to let an operator set a new one over it would repeat the
+		 * corrupted-record-blocks-everything failure this API is built to avoid.
+		 */
+		if ( is_array( $existing ) && isset( $existing['current'] ) && is_array( $existing['current'] ) ) {
+			$demoted = $this->demote_slot( $cipher, $master_key, $scope, $site_id, $name, $existing['current'] );
+
+			if ( ! is_wp_error( $demoted ) ) {
+				$record['previous'] = $demoted;
+			}
+		}
+
+		wp_secrets_memzero( $master_key );
+
+		$result = $store->set( $name, $record, $network );
+
+		if ( is_wp_error( $result ) ) {
+			return $result;
+		}
+
+		$is_update       = is_array( $existing );
+		$old_fingerprint = $this->stored_fingerprint( $existing );
+
+		/**
+		 * Fires whenever a secret is created, updated, deleted, imported, or retired.
+		 *
+		 * The only hook in the core-bound Secrets API code, and it does not fire on the
+		 * retrieval path. Fingerprints are readable; values are never passed.
+		 *
+		 * @since 7.2.0
+		 *
+		 * @param string $name            The secret's namespaced name.
+		 * @param string $action          One of 'created', 'updated', 'deleted',
+		 *                                'imported', 'retired'.
+		 * @param int    $actor_id        The current user id, or 0.
+		 * @param int    $timestamp       Unix timestamp of the change.
+		 * @param string $old_fingerprint The previous fingerprint, or '' if none.
+		 * @param string $new_fingerprint The new fingerprint, or '' if the secret was deleted.
+		 * @param bool   $network         Whether the secret is network-scope. A site-scope
+		 *                                secret belongs to the current site.
+		 */
+		do_action(
+			'wp_secret_changed',
+			$name,
+			null !== $action ? $action : ( $is_update ? 'updated' : 'created' ),
+			get_current_user_id(),
+			$new_slot['created'],
+			$old_fingerprint,
+			$new_slot['fingerprint'],
+			$network
+		);
+
+		return true;
+	}
+
+	/**
+	 * Shared implementation behind wp_get_secret() and wp_get_network_secret().
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's namespaced name.
+	 * @param string $version A WP_Secret_Version constant.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return WP_Secret|null|WP_Error
+	 *
+	 * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version
+	 */
+	public function get( string $name, string $version, bool $network = false ) {
+
+		$name_check = wp_secrets_validate_name( $name );
+
+		if ( is_wp_error( $name_check ) ) {
+			return $name_check;
+		}
+
+		$record = $this->store->get( $name, $network );
+
+		if ( is_wp_error( $record ) ) {
+			return $record;
+		}
+
+		if ( null === $record ) {
+			return null;
+		}
+
+		$shape_check = $this->validate_record_shape( $record );
+
+		if ( is_wp_error( $shape_check ) ) {
+			return $shape_check;
+		}
+
+		if ( ! isset( $record[ $version ] ) || ! is_array( $record[ $version ] ) ) {
+			// The secret exists, but this slot does not. Absence, not an error –
+			// this matters most for PREVIOUS on a secret that has never been rotated.
+			return null;
+		}
+
+		$scope   = $network ? 'network' : 'site';
+		$site_id = $network ? 0 : get_current_blog_id();
+
+		$master_key = $this->key_manager->get_master_key( $scope, $network ? null : $site_id );
+
+		if ( is_wp_error( $master_key ) ) {
+			return $master_key;
+		}
+
+		$cipher    = new WP_Secrets_Cipher();
+		$plaintext = $cipher->decrypt_value( $master_key, $scope, $site_id, $name, $version, $record[ $version ] );
+
+		if ( is_wp_error( $plaintext ) ) {
+			wp_secrets_memzero( $master_key );
+
+			return $plaintext;
+		}
+
+		/*
+		 * Recomputed from the plaintext just decrypted, never read back from the record.
+		 * The stored 'fingerprint' field sits outside the AAD and so is not
+		 * authenticated: anyone who can write to the store can set it to anything
+		 * without disturbing the ciphertext. Trusting it would make
+		 * WP_Secret::fingerprint() attacker-controlled – which matters most where a
+		 * fingerprint comparison gates something irreversible, such as a migration
+		 * verifying a value before deleting its source. The stored copy still exists,
+		 * but only so wp_list_secrets() has something to show without decrypting.
+		 */
+		$fingerprint = $cipher->fingerprint( $master_key, $plaintext );
+
+		wp_secrets_memzero( $master_key );
+
+		if ( is_wp_error( $fingerprint ) ) {
+			wp_secrets_memzero( $plaintext );
+
+			return $fingerprint;
+		}
+
+		$secret = new WP_Secret( $name, $plaintext, $fingerprint );
+
+		wp_secrets_memzero( $plaintext );
+
+		return $secret;
+	}
+
+	/**
+	 * Shared implementation behind wp_delete_secret() and wp_delete_network_secret().
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's namespaced name.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return true|WP_Error
+	 */
+	public function delete( string $name, bool $network = false ) {
+		$name_check = wp_secrets_validate_name( $name );
+
+		if ( is_wp_error( $name_check ) ) {
+			return $name_check;
+		}
+
+		$store = $this->store;
+
+		$existing = $this->read_prior_record( $store, $name, $network );
+
+		if ( is_wp_error( $existing ) ) {
+			return $existing;
+		}
+
+		$result = $store->delete( $name, $network );
+
+		if ( is_wp_error( $result ) ) {
+			return $result;
+		}
+
+		if ( is_array( $existing ) ) {
+			/** This action is documented in wp-includes/secrets/class-wp-secrets-libsodium-provider.php */
+			do_action(
+				'wp_secret_changed',
+				$name,
+				'deleted',
+				get_current_user_id(),
+				time(),
+				$this->stored_fingerprint( $existing ),
+				'',
+				$network
+			);
+		}
+
+		return true;
+	}
+
+	/**
+	 * Shared implementation behind the two version-retirement functions.
+	 *
+	 * Backs wp_retire_secret_version() and wp_retire_network_secret_version().
+	 *
+	 * Beyond the API surface the proposal names. It states that retirement is "an
+	 * explicit operator action" but names no function; this is this implementation's
+	 * name for it, pending confirmation in the comments thread.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's namespaced name.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return true|WP_Error
+	 */
+	public function retire_previous( string $name, bool $network = false ) {
+		$name_check = wp_secrets_validate_name( $name );
+
+		if ( is_wp_error( $name_check ) ) {
+			return $name_check;
+		}
+
+		$store  = $this->store;
+		$record = $store->get( $name, $network );
+
+		if ( is_wp_error( $record ) ) {
+			return $record;
+		}
+
+		if ( null === $record || ! isset( $record['previous'] ) ) {
+			/*
+			 * The goal of retirement – "there is no previous slot" – is already
+			 * true, whether because the secret doesn't exist or because it was
+			 * never rotated. A successful no-op, not an error, and deliberately
+			 * checked before the write-support check below: a store that can't
+			 * accept writes can still report a correct "nothing to do" without
+			 * that being mistaken for the store refusing the operation.
+			 */
+			return true;
+		}
+
+		$retired_fingerprint = $this->slot_fingerprint( $record['previous'] );
+
+		unset( $record['previous'] );
+
+		$result = $store->set( $name, $record, $network );
+
+		if ( is_wp_error( $result ) ) {
+			return $result;
+		}
+
+		/** This action is documented in wp-includes/secrets/class-wp-secrets-libsodium-provider.php */
+		do_action(
+			'wp_secret_changed',
+			$name,
+			'retired',
+			get_current_user_id(),
+			time(),
+			$retired_fingerprint,
+			'',
+			$network
+		);
+
+		return true;
+	}
+
+	/**
+	 * Shared implementation behind wp_list_secrets() and wp_list_network_secrets().
+	 *
+	 * Beyond the API surface the proposal names, and justified by its statement that
+	 * "the hooks and accessors an admin screen would need are in scope now; the screen
+	 * itself is not."
+	 *
+	 * Fingerprints returned here come directly from the stored record field, not
+	 * recomputed by decrypting each secret. That is a deliberate difference from
+	 * WP_Secret::fingerprint(), which always recomputes – recomputing here would mean
+	 * decrypting every matching secret just to list them, defeating the point of a
+	 * lightweight listing call. This is safe specifically because a list entry is
+	 * documented as informational only and
+	 * is never used to gate anything; nothing in this codebase performs a security
+	 * decision based on a fingerprint returned from this function.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name_prefix Only secrets whose name starts with "{$name_prefix}/"
+	 *                            are returned. Default '' returns every secret in this
+	 *                            scope. Named to match the public function's own
+	 *                            $namespace parameter, without using the reserved word
+	 *                            'namespace' in an internal signature.
+	 * @param bool   $network     Whether to list network-scope secrets.
+	 * @return array|WP_Error Array of associative arrays, each with keys 'name',
+	 *                        'fingerprint', 'created', 'has_previous', and
+	 *                        'needs_rotation'. Never a value. WP_Error on failure.
+	 *
+	 * @phpstan-return list<array{name: string, fingerprint: string, created: int, has_previous: bool, needs_rotation: bool}>|WP_Error
+	 */
+	public function list_secrets( string $name_prefix = '', bool $network = false ) {
+
+		$store = $this->store;
+
+		$names = $store->list_names( $network );
+
+		if ( is_wp_error( $names ) ) {
+			return $names;
+		}
+
+		$entries = array();
+
+		foreach ( $names as $name ) {
+			if ( '' !== $name_prefix && 0 !== strpos( $name, $name_prefix . '/' ) ) {
+				continue;
+			}
+
+			$record = $store->get( $name, $network );
+
+			if ( ! is_wp_error( $record ) && null !== $record && true === $this->validate_record_shape( $record ) && is_array( $record['current'] ) ) {
+				$entries[] = array(
+					'name'           => $name,
+					'fingerprint'    => $this->stored_fingerprint( $record ),
+					'created'        => isset( $record['current']['created'] ) && is_int( $record['current']['created'] ) ? $record['current']['created'] : 0,
+					'has_previous'   => isset( $record['previous'] ) && is_array( $record['previous'] ),
+					'needs_rotation' => ! empty( $record['current']['needs_rotation'] ),
+				);
+
+				continue;
+			}
+
+			/*
+			 * A corrupted or unreadable record is still listed, with whatever
+			 * metadata could not be salvaged left blank, rather than silently
+			 * omitted. Site Health's "undecryptable secrets" check depends on
+			 * exactly this: a secret that has gone bad must remain visible.
+			 */
+			$entries[] = array(
+				'name'           => $name,
+				'fingerprint'    => '',
+				'created'        => 0,
+				'has_previous'   => false,
+				'needs_rotation' => false,
+			);
+		}
+
+		return $entries;
+	}
+}
diff --git a/wp-includes/secrets/class-wp-secrets-option-store.php b/wp-includes/secrets/class-wp-secrets-option-store.php
new file mode 100644
index 0000000000..7ffc149652
--- /dev/null
+++ b/wp-includes/secrets/class-wp-secrets-option-store.php
@@ -0,0 +1,207 @@
+<?php
+/**
+ * Secrets API: WP_Secrets_Option_Store class
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * The default secret store.
+ *
+ * Keeps records as rows in the options tables, always with autoload disabled, and
+ * always excluded from options.php and the REST settings endpoint.
+ *
+ * Site-scope secrets live under '_wp_secret_{name}' via get_option()/update_option().
+ * Network-scope secrets live under '_wp_network_secret_{name}' via the *_site_option()
+ * functions, which on a non-multisite install are themselves backed by wp_options –
+ * so on a single site, site- and network-scope secrets differ only by prefix, in the
+ * same table; on a real network, network-scope rows live in wp_sitemeta instead.
+ *
+ * @since 7.2.0
+ */
+final class WP_Secrets_Option_Store implements WP_Secrets_Store {
+
+	/**
+	 * Option name prefix for site-scope secrets.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const SITE_PREFIX = '_wp_secret_';
+
+	/**
+	 * Option name prefix for network-scope secrets.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const NETWORK_PREFIX = '_wp_network_secret_';
+
+	/**
+	 * Reads a secret's stored record.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's namespaced name.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return array<mixed>|null|WP_Error
+	 */
+	public function get( string $name, bool $network = false ) {
+		$option_name = $this->option_name( $name, $network );
+		$value       = $network ? get_site_option( $option_name, null ) : get_option( $option_name, null );
+
+		if ( null === $value ) {
+			return null;
+		}
+
+		if ( ! is_array( $value ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_RECORD_MALFORMED,
+				__( 'The stored secret record is not an array.' )
+			);
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Writes a secret's record.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string       $name    The secret's namespaced name.
+	 * @param array<mixed> $record  The record to store.
+	 * @param bool         $network Whether this is a network-scope secret.
+	 * @return true|WP_Error
+	 */
+	public function set( string $name, array $record, bool $network = false ) {
+		$option_name = $this->option_name( $name, $network );
+
+		if ( $network ) {
+			$updated = update_site_option( $option_name, $record );
+		} else {
+			$updated = update_option( $option_name, $record, false );
+		}
+
+		if ( $updated ) {
+			return true;
+		}
+
+		/*
+		 * update_option()/update_site_option() return false both on genuine
+		 * failure and when the new value already equals the stored one – a
+		 * documented ambiguity in the Options API. Read back and compare rather
+		 * than treating every false as a failure.
+		 */
+		$current = $network ? get_site_option( $option_name ) : get_option( $option_name );
+
+		if ( $current === $record ) {
+			return true;
+		}
+
+		return new WP_Error(
+			WP_SECRETS_ERROR_STORE_UNAVAILABLE,
+			__( 'The secret record could not be written.' )
+		);
+	}
+
+	/**
+	 * Deletes a secret's record.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's namespaced name.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return true|WP_Error
+	 */
+	public function delete( string $name, bool $network = false ) {
+		$option_name = $this->option_name( $name, $network );
+		$deleted     = $network ? delete_site_option( $option_name ) : delete_option( $option_name );
+
+		if ( $deleted ) {
+			return true;
+		}
+
+		// delete_*_option() also returns false when the option never existed.
+		// Deleting something already absent is not a failure.
+		$still_exists = null !== ( $network ? get_site_option( $option_name, null ) : get_option( $option_name, null ) );
+
+		if ( ! $still_exists ) {
+			return true;
+		}
+
+		return new WP_Error(
+			WP_SECRETS_ERROR_STORE_UNAVAILABLE,
+			__( 'The secret record could not be deleted.' )
+		);
+	}
+
+	/**
+	 * Lists the names of every secret in this store, for this scope.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @global wpdb $wpdb WordPress database abstraction object.
+	 *
+	 * @param bool $network Whether to list network-scope secrets.
+	 * @return string[]|WP_Error
+	 */
+	public function list_names( bool $network = false ) {
+		global $wpdb;
+
+		$prefix  = $network ? self::NETWORK_PREFIX : self::SITE_PREFIX;
+		$pattern = $wpdb->esc_like( $prefix ) . '%';
+
+		if ( $network && is_multisite() ) {
+			$option_names = $wpdb->get_col(
+				$wpdb->prepare(
+					"SELECT meta_key FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s AND site_id = %d",
+					$pattern,
+					get_current_network_id()
+				)
+			);
+		} else {
+			$option_names = $wpdb->get_col(
+				$wpdb->prepare(
+					"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
+					$pattern
+				)
+			);
+		}
+
+		if ( ! is_array( $option_names ) ) {
+			return new WP_Error(
+				WP_SECRETS_ERROR_STORE_UNAVAILABLE,
+				__( 'Could not list secrets.' )
+			);
+		}
+
+		$names = array();
+
+		foreach ( $option_names as $option_name ) {
+			if ( ! is_string( $option_name ) ) {
+				continue;
+			}
+
+			$names[] = substr( $option_name, strlen( $prefix ) );
+		}
+
+		return $names;
+	}
+
+
+	/**
+	 * Builds the option name a secret is stored under.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's namespaced name.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return string
+	 */
+	private function option_name( string $name, bool $network ): string {
+		return ( $network ? self::NETWORK_PREFIX : self::SITE_PREFIX ) . $name;
+	}
+}
diff --git a/wp-includes/secrets/interface-wp-secrets-keyring.php b/wp-includes/secrets/interface-wp-secrets-keyring.php
new file mode 100644
index 0000000000..d378062908
--- /dev/null
+++ b/wp-includes/secrets/interface-wp-secrets-keyring.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Secrets API: WP_Secrets_Keyring interface
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * Wraps and unwraps the root key that everything else in this API derives from.
+ *
+ * A KMS or HSM lives behind this interface in a production deployment. An
+ * implementation is never handed a plaintext secret, only 32 bytes of key material,
+ * and cannot turn encryption off. There is no method here that
+ * accepts a plaintext secret value at all.
+ *
+ * An implementation should run WP_Secrets_Keyring_Conformance against itself before
+ * shipping.
+ *
+ * @since 7.2.0
+ */
+interface WP_Secrets_Keyring {
+
+	/**
+	 * Wraps (encrypts) raw key material for storage.
+	 *
+	 * Must not be deterministic: two calls with the same key material must return
+	 * different values. WP_Secrets_Key_Manager::rotate_site_key() stores the
+	 * re-wrapped value with update_site_option(), which reports an unchanged value
+	 * as a failure, and WP_Secrets_Keyring_Conformance checks this.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $key_material Raw key material to protect.
+	 * @return string|WP_Error Opaque wrapped value on success, WP_Error on failure.
+	 */
+	public function wrap( string $key_material );
+
+	/**
+	 * Unwraps (decrypts) previously wrapped key material.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $wrapped An opaque value previously returned by wrap().
+	 * @return string|WP_Error Raw key material on success, WP_Error on failure.
+	 */
+	public function unwrap( string $wrapped );
+
+	/**
+	 * A human-readable description of the key source, for Site Health.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 */
+	public function get_key_source(): string;
+}
diff --git a/wp-includes/secrets/interface-wp-secrets-provider.php b/wp-includes/secrets/interface-wp-secrets-provider.php
new file mode 100644
index 0000000000..bd11a88c71
--- /dev/null
+++ b/wp-includes/secrets/interface-wp-secrets-provider.php
@@ -0,0 +1,227 @@
+<?php
+/**
+ * Secrets API: WP_Secrets_Provider interface
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * Interface for a secrets provider.
+ *
+ * A provider is responsible for a secret: storing it, protecting it, and returning
+ * it to a caller. It is the outermost extension point in the Secrets API, and the
+ * one a hosting platform implements.
+ *
+ * WordPress ships WP_Secrets_Libsodium_Provider, which encrypts with libsodium and
+ * stores ciphertext in the options tables. That provider is the default rather than
+ * a privileged case: a platform that protects credentials in a key management
+ * service, a hardware security module, or its own control panel implements this
+ * same interface.
+ *
+ * A provider must be stronger than the default, never weaker. Storing a plaintext
+ * where the default would have stored ciphertext is what this interface exists to
+ * prevent. Receiving a value over an authenticated channel and holding it in a
+ * hardware security module satisfies that requirement.
+ *
+ * WordPress cannot verify any of this. A provider is loaded from a drop-in, which is
+ * fully trusted code: it runs before plugins, and could already read every secret by
+ * implementing the keyring. get_protection_boundary() and is_writable() are
+ * declarations rather than enforcement. They exist so that a developer reviewing a
+ * drop-in can see what it claims, Site Health can report where credentials are
+ * protected, and a settings screen can determine that a write will be refused before
+ * an operator enters a credential. They are not a security boundary.
+ *
+ * Every implementation shares these contracts:
+ *
+ * - Three states, never collapsed. get() returns a WP_Secret, null when the secret
+ *   does not exist, or WP_Error when it exists but cannot be produced. Reporting an
+ *   unreachable backend as absent turns an outage into an apparently deleted
+ *   credential.
+ * - Fail closed. A provider that cannot answer returns WP_Error. It never
+ *   substitutes a weaker source, and never returns a partial or placeholder value.
+ * - No filter on the retrieval path. A provider replaces a component; it is not a
+ *   hook. Nothing is given the opportunity to observe or alter a value in transit.
+ * - Never persist a value more weakly than the provider protects it. A provider that
+ *   fetches over the network must not write the plaintext into the persistent object
+ *   cache. WP_Secret cannot round-trip a plaintext through wp_cache_set(), and
+ *   caching the raw value alongside it would undo that. Memoize within the request
+ *   only.
+ *
+ * @since 7.2.0
+ */
+interface WP_Secrets_Provider {
+
+	/**
+	 * Protection happens inside WordPress.
+	 *
+	 * The provider holds ciphertext that WordPress produced, and WordPress holds the
+	 * key material.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const BOUNDARY_WORDPRESS = 'wordpress';
+
+	/**
+	 * Protection happens outside WordPress.
+	 *
+	 * The provider is itself the encryption boundary, and WordPress is a consumer of a
+	 * credential it does not protect.
+	 *
+	 * @since 7.2.0
+	 * @var string
+	 */
+	const BOUNDARY_PROVIDER = 'provider';
+
+	/**
+	 * Retrieves a secret.
+	 *
+	 * Returning null means "I am not responsible for this name" as well as "this
+	 * name does not exist". The two are the same answer from a caller's point of
+	 * view, and keeping them distinct would require a provider to enumerate names
+	 * it has never heard of.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's name.
+	 * @param string $version A WP_Secret_Version constant. A provider with no
+	 *                        version history returns null for PREVIOUS, which is
+	 *                        absence rather than an error.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return WP_Secret|null|WP_Error
+	 *
+	 * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version
+	 */
+	public function get( string $name, string $version, bool $network = false );
+
+	/**
+	 * Stores a secret.
+	 *
+	 * A provider whose credentials are managed elsewhere, by a control panel, host
+	 * tooling, or a KMS with its own access policy, returns WP_Error here with code
+	 * WP_SECRETS_ERROR_PROVIDER_READ_ONLY, and reports false from is_writable() so
+	 * that callers can find that out without attempting the write first.
+	 *
+	 * Implementations are responsible for firing the `wp_secret_changed` action once
+	 * the write has succeeded, with `$network` as its last argument. The API does
+	 * not fire it on the provider's behalf, because only the provider knows the
+	 * prior fingerprint without paying for an additional read. An audit hook that
+	 * stopped firing once a host installed a provider would be unreliable in exactly
+	 * the situation it matters most, so WP_Secrets_Provider_Conformance checks it.
+	 * A write that fails fires nothing.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string      $name           The secret's name.
+	 * @param string      $value          The plaintext value.
+	 * @param bool        $network        Whether this is a network-scope secret.
+	 * @param bool        $needs_rotation Mark the stored secret as needing rotation.
+	 *                                    Set for values that arrived from somewhere
+	 *                                    less protected than this provider, such as
+	 *                                    an imported option. A provider with nowhere
+	 *                                    to record this may ignore it, but must not
+	 *                                    report it as honored.
+	 * @param string|null $action         Overrides the action reported to
+	 *                                    `wp_secret_changed`; null means the
+	 *                                    provider decides between 'created' and
+	 *                                    'updated'.
+	 * @return true|WP_Error
+	 */
+	public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null );
+
+	/**
+	 * Deletes a secret.
+	 *
+	 * Deleting a secret that does not exist is a success.
+	 *
+	 * Fires `wp_secret_changed` with the 'deleted' action once a secret has been
+	 * deleted, for the reason given on set(). A provider that can tell nothing was
+	 * there to delete fires nothing.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's name.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return true|WP_Error
+	 */
+	public function delete( string $name, bool $network = false );
+
+	/**
+	 * Clears a secret's previous version, if this provider keeps one.
+	 *
+	 * A provider with no version history treats this as a successful no-op: there
+	 * is nothing to retire, which is the state the caller asked for.
+	 *
+	 * Fires `wp_secret_changed` with the 'retired' action once a previous version
+	 * has been cleared, for the reason given on set(). Nothing fires when there was
+	 * no previous version to clear.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's name.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return true|WP_Error
+	 */
+	public function retire_previous( string $name, bool $network = false );
+
+	/**
+	 * Lists secrets by name and metadata. Never values.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name_prefix Restrict to names beginning with this prefix, or
+	 *                            '' for all of them.
+	 * @param bool   $network     Whether to list network-scope secrets.
+	 * @return array|WP_Error List of arrays with keys 'name', 'fingerprint',
+	 *                        'created', 'has_previous', 'needs_rotation'.
+	 *
+	 * @phpstan-return list<array{name: string, fingerprint: string, created: int, has_previous: bool, needs_rotation: bool}>|WP_Error
+	 */
+	public function list_secrets( string $name_prefix = '', bool $network = false );
+
+	/**
+	 * A short human-readable name for this provider, for Site Health.
+	 *
+	 * Describes the protection, not the vendor's marketing: "AWS KMS
+	 * (alias/wp-secrets)" tells an operator something, "SuperSecure Pro" does not.
+	 * Never key material, never a credential, never a value.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 */
+	public function get_label(): string;
+
+	/**
+	 * Where secrets this provider holds are actually protected.
+	 *
+	 * One of the BOUNDARY_* constants. This is what lets Site Health and a future
+	 * admin screen tell an operator whether their credentials are protected by
+	 * WordPress's own libsodium envelope or by something outside it, a question
+	 * they currently have no way to answer, and the one hosts asked to be able to
+	 * answer honestly.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return string
+	 *
+	 * @phpstan-return self::BOUNDARY_*
+	 */
+	public function get_protection_boundary(): string;
+
+	/**
+	 * Whether wp_set_secret() can succeed against this provider.
+	 *
+	 * False for a provider whose credentials are managed by host tooling or a
+	 * control panel. Declared rather than discovered so a settings screen can
+	 * disable its save control before an operator types a credential into a field
+	 * that will only reject it.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @return bool
+	 */
+	public function is_writable(): bool;
+}
diff --git a/wp-includes/secrets/interface-wp-secrets-store.php b/wp-includes/secrets/interface-wp-secrets-store.php
new file mode 100644
index 0000000000..81759a483b
--- /dev/null
+++ b/wp-includes/secrets/interface-wp-secrets-store.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Secrets API: WP_Secrets_Store interface
+ *
+ * @package WordPress
+ * @subpackage Secrets
+ * @since 7.2.0
+ */
+
+/**
+ * Where a secret's encrypted record lives.
+ *
+ * An implementation is never handed a plaintext secret, only the record array
+ * produced by WP_Secrets_Cipher and assembled by the functions in secrets.php, and
+ * cannot turn encryption off; there is no method here that accepts or returns
+ * anything but ciphertext-bearing structures.
+ *
+ * A platform store that is read-only from the application, with credentials managed
+ * by a separate CLI or dashboard, returns WP_Error from set() rather than pretending
+ * the write succeeded. Providers declare writability up front through
+ * WP_Secrets_Provider::is_writable().
+ *
+ * @since 7.2.0
+ */
+interface WP_Secrets_Store {
+
+	/**
+	 * Reads a secret's stored record.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's namespaced name.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return array<mixed>|null|WP_Error The record array if it exists. Null if it does not.
+	 *                             WP_Error if it could not be determined which.
+	 */
+	public function get( string $name, bool $network = false );
+
+	/**
+	 * Writes a secret's record.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string       $name    The secret's namespaced name.
+	 * @param array<mixed> $record  The record to store.
+	 * @param bool         $network Whether this is a network-scope secret.
+	 * @return bool|WP_Error True on success. WP_Error on failure, including when the
+	 *                       store does not accept writes.
+	 */
+	public function set( string $name, array $record, bool $network = false );
+
+	/**
+	 * Deletes a secret's record.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param string $name    The secret's namespaced name.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return bool|WP_Error True on success (including if it did not exist).
+	 *                       WP_Error on failure.
+	 */
+	public function delete( string $name, bool $network = false );
+
+	/**
+	 * Lists the names of every secret in this store, for this scope. Never a value.
+	 *
+	 * @since 7.2.0
+	 *
+	 * @param bool $network Whether to list network-scope secrets.
+	 * @return string[]|WP_Error Array of secret names on success. WP_Error on failure.
+	 */
+	public function list_names( bool $network = false );
+}
diff --git a/wp-includes/version.php b/wp-includes/version.php
index 4943ec7a33..660bcfab2d 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,14 +16,14 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.2-alpha-64336';
+$wp_version = '7.2-alpha-64337';

 /**
  * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.
  *
  * @global int $wp_db_version
  */
-$wp_db_version = 61833;
+$wp_db_version = 64337;

 /**
  * Holds the TinyMCE version.
diff --git a/wp-settings.php b/wp-settings.php
index 07756eb048..c138458349 100644
--- a/wp-settings.php
+++ b/wp-settings.php
@@ -311,6 +311,20 @@ require ABSPATH . WPINC . '/nav-menu-template.php';
 require ABSPATH . WPINC . '/nav-menu.php';
 require ABSPATH . WPINC . '/admin-bar.php';
 require ABSPATH . WPINC . '/class-wp-application-passwords.php';
+require ABSPATH . WPINC . '/secrets.php';
+require ABSPATH . WPINC . '/secrets/class-wp-secret-version.php';
+require ABSPATH . WPINC . '/secrets/class-wp-secret.php';
+require ABSPATH . WPINC . '/secrets/interface-wp-secrets-provider.php';
+require ABSPATH . WPINC . '/secrets/interface-wp-secrets-keyring.php';
+require ABSPATH . WPINC . '/secrets/interface-wp-secrets-store.php';
+require ABSPATH . WPINC . '/secrets/class-wp-secrets-config-key-provider.php';
+require ABSPATH . WPINC . '/secrets/class-wp-secrets-broken-keyring.php';
+require ABSPATH . WPINC . '/secrets/class-wp-secrets-cipher.php';
+require ABSPATH . WPINC . '/secrets/class-wp-secrets-key-manager.php';
+require ABSPATH . WPINC . '/secrets/class-wp-secrets-option-store.php';
+require ABSPATH . WPINC . '/secrets/class-wp-secrets-broken-store.php';
+require ABSPATH . WPINC . '/secrets/class-wp-secrets-libsodium-provider.php';
+require ABSPATH . WPINC . '/secrets/class-wp-secrets-broken-provider.php';
 require ABSPATH . WPINC . '/abilities-api/class-wp-ability-category.php';
 require ABSPATH . WPINC . '/abilities-api/class-wp-ability-categories-registry.php';
 require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php';
@@ -508,6 +522,9 @@ $GLOBALS['wp_plugin_paths'] = array();
 // To make get_plugin_data() available for both network-activated and site-activated plugins, see #62244 and #64249.
 require_once ABSPATH . 'wp-admin/includes/plugin.php';

+// Load the secrets.php drop-in, which may replace the Secrets API's provider, store, or keyring.
+wp_load_secrets_dropin();
+
 // Load must-use plugins.
 foreach ( wp_get_mu_plugins() as $mu_plugin ) {
 	$_wp_plugin_file = $mu_plugin;