Index: /trunk/src/wp-admin/includes/class-wp-site-health.php
===================================================================
--- /trunk/src/wp-admin/includes/class-wp-site-health.php	(revision 64336)
+++ /trunk/src/wp-admin/includes/class-wp-site-health.php	(revision 64337)
@@ -7,4 +7,7 @@
  * @since 5.2.0
  */
+
+
+require_once __DIR__ . '/secrets-site-health.php';
 
 #[AllowDynamicProperties]
Index: /trunk/src/wp-admin/includes/schema.php
===================================================================
--- /trunk/src/wp-admin/includes/schema.php	(revision 64336)
+++ /trunk/src/wp-admin/includes/schema.php	(revision 64337)
@@ -733,4 +733,5 @@
 	populate_roles_280();
 	populate_roles_300();
+	populate_roles_720();
 
 	// Save the updated roles to the database.
@@ -969,4 +970,17 @@
 }
 
+/**
+ * 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' ) ) :
 	/**
Index: /trunk/src/wp-admin/includes/secrets-site-health.php
===================================================================
--- /trunk/src/wp-admin/includes/secrets-site-health.php	(revision 64337)
+++ /trunk/src/wp-admin/includes/secrets-site-health.php	(revision 64337)
@@ -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' );
Index: /trunk/src/wp-admin/includes/upgrade.php
===================================================================
--- /trunk/src/wp-admin/includes/upgrade.php	(revision 64336)
+++ /trunk/src/wp-admin/includes/upgrade.php	(revision 64337)
@@ -893,4 +893,8 @@
 	}
 
+	if ( $wp_current_db_version < 64337 ) {
+		upgrade_720();
+	}
+
 	maybe_disable_link_manager();
 
@@ -2504,4 +2508,21 @@
 
 /**
+ * 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.
  *
Index: /trunk/src/wp-includes/capabilities.php
===================================================================
--- /trunk/src/wp-includes/capabilities.php	(revision 64336)
+++ /trunk/src/wp-includes/capabilities.php	(revision 64337)
@@ -768,4 +768,5 @@
 		case 'manage_network_themes':
 		case 'manage_network_options':
+		case 'manage_network_secrets':
 		case 'upgrade_network':
 			$caps[] = $cap;
Index: /trunk/src/wp-includes/load.php
===================================================================
--- /trunk/src/wp-includes/load.php	(revision 64336)
+++ /trunk/src/wp-includes/load.php	(revision 64337)
@@ -826,4 +826,68 @@
 
 /**
+ * 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.
  *
Index: /trunk/src/wp-includes/secrets.php
===================================================================
--- /trunk/src/wp-includes/secrets.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets.php	(revision 64337)
@@ -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 );
+}
Index: /trunk/src/wp-includes/secrets/class-wp-secret-version.php
===================================================================
--- /trunk/src/wp-includes/secrets/class-wp-secret-version.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets/class-wp-secret-version.php	(revision 64337)
@@ -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';
+}
Index: /trunk/src/wp-includes/secrets/class-wp-secret.php
===================================================================
--- /trunk/src/wp-includes/secrets/class-wp-secret.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets/class-wp-secret.php	(revision 64337)
@@ -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 ] );
+		}
+	}
+}
Index: /trunk/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php
===================================================================
--- /trunk/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php	(revision 64337)
@@ -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.' )
+		);
+	}
+}
Index: /trunk/src/wp-includes/secrets/class-wp-secrets-broken-provider.php
===================================================================
--- /trunk/src/wp-includes/secrets/class-wp-secrets-broken-provider.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets/class-wp-secrets-broken-provider.php	(revision 64337)
@@ -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;
+	}
+}
Index: /trunk/src/wp-includes/secrets/class-wp-secrets-broken-store.php
===================================================================
--- /trunk/src/wp-includes/secrets/class-wp-secrets-broken-store.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets/class-wp-secrets-broken-store.php	(revision 64337)
@@ -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.' )
+		);
+	}
+}
Index: /trunk/src/wp-includes/secrets/class-wp-secrets-cipher.php
===================================================================
--- /trunk/src/wp-includes/secrets/class-wp-secrets-cipher.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets/class-wp-secrets-cipher.php	(revision 64337)
@@ -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;
+	}
+}
Index: /trunk/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php
===================================================================
--- /trunk/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php	(revision 64337)
@@ -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;
+	}
+}
Index: /trunk/src/wp-includes/secrets/class-wp-secrets-key-manager.php
===================================================================
--- /trunk/src/wp-includes/secrets/class-wp-secrets-key-manager.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets/class-wp-secrets-key-manager.php	(revision 64337)
@@ -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;
+	}
+}
Index: /trunk/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php
===================================================================
--- /trunk/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php	(revision 64337)
@@ -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;
+	}
+}
Index: /trunk/src/wp-includes/secrets/class-wp-secrets-option-store.php
===================================================================
--- /trunk/src/wp-includes/secrets/class-wp-secrets-option-store.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets/class-wp-secrets-option-store.php	(revision 64337)
@@ -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;
+	}
+}
Index: /trunk/src/wp-includes/secrets/interface-wp-secrets-keyring.php
===================================================================
--- /trunk/src/wp-includes/secrets/interface-wp-secrets-keyring.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets/interface-wp-secrets-keyring.php	(revision 64337)
@@ -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;
+}
Index: /trunk/src/wp-includes/secrets/interface-wp-secrets-provider.php
===================================================================
--- /trunk/src/wp-includes/secrets/interface-wp-secrets-provider.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets/interface-wp-secrets-provider.php	(revision 64337)
@@ -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;
+}
Index: /trunk/src/wp-includes/secrets/interface-wp-secrets-store.php
===================================================================
--- /trunk/src/wp-includes/secrets/interface-wp-secrets-store.php	(revision 64337)
+++ /trunk/src/wp-includes/secrets/interface-wp-secrets-store.php	(revision 64337)
@@ -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 );
+}
Index: /trunk/src/wp-includes/version.php
===================================================================
--- /trunk/src/wp-includes/version.php	(revision 64336)
+++ /trunk/src/wp-includes/version.php	(revision 64337)
@@ -24,5 +24,5 @@
  * @global int $wp_db_version
  */
-$wp_db_version = 61833;
+$wp_db_version = 64337;
 
 /**
Index: /trunk/src/wp-settings.php
===================================================================
--- /trunk/src/wp-settings.php	(revision 64336)
+++ /trunk/src/wp-settings.php	(revision 64337)
@@ -312,4 +312,18 @@
 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';
@@ -509,4 +523,7 @@
 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 ) {
Index: /trunk/tests/phpstan/base.neon
===================================================================
--- /trunk/tests/phpstan/base.neon	(revision 64336)
+++ /trunk/tests/phpstan/base.neon	(revision 64337)
@@ -133,4 +133,6 @@
 		- HEADER_IMAGE_WIDTH
 		- HEADER_TEXTCOLOR
+		- LOGGED_IN_KEY
+		- LOGGED_IN_SALT
 		- MEDIA_TRASH
 		- MULTISITE
Index: /trunk/tests/phpunit/includes/bootstrap.php
===================================================================
--- /trunk/tests/phpunit/includes/bootstrap.php	(revision 64336)
+++ /trunk/tests/phpunit/includes/bootstrap.php	(revision 64337)
@@ -29,4 +29,19 @@
 
 require_once $config_file_path;
+
+/*
+ * The Secrets API will not derive a key from the placeholder salts that
+ * wp-tests-config-sample.php ships with, so give the suite a fixed test key.
+ *
+ * Not in a process-isolated test: PHPUnit sets $GLOBALS['__PHPUNIT_BOOTSTRAP']
+ * while it loads this file in a child process, and the Secrets API tests that
+ * run isolated do so precisely to define WP_SECRETS_KEY themselves, or to
+ * leave it undefined.
+ */
+if ( ! defined( 'WP_SECRETS_KEY' ) && ! isset( $GLOBALS['__PHPUNIT_BOOTSTRAP'] )
+	&& defined( 'LOGGED_IN_KEY' ) && 'put your unique phrase here' === LOGGED_IN_KEY
+) {
+	define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'k', 32 ) ) );
+}
 require_once __DIR__ . '/functions.php';
 
@@ -334,4 +349,9 @@
 require __DIR__ . '/class-wp-sitemaps-empty-test-provider.php';
 require __DIR__ . '/class-wp-sitemaps-large-test-provider.php';
+require __DIR__ . '/secrets/trait-wp-secrets-assertions.php';
+require __DIR__ . '/secrets/class-mock-store.php';
+require __DIR__ . '/secrets/class-mock-keyring.php';
+require __DIR__ . '/secrets/class-wp-secrets-provider-conformance.php';
+require __DIR__ . '/secrets/class-wp-secrets-keyring-conformance.php';
 
 /**
Index: /trunk/tests/phpunit/includes/secrets/class-mock-keyring.php
===================================================================
--- /trunk/tests/phpunit/includes/secrets/class-mock-keyring.php	(revision 64337)
+++ /trunk/tests/phpunit/includes/secrets/class-mock-keyring.php	(revision 64337)
@@ -0,0 +1,128 @@
+<?php
+/**
+ * Configurable WP_Secrets_Keyring test double. Not real cryptography – a
+ * non-deterministic transform with an integrity tag, just enough to stand in for
+ * a real keyring under the conformance suite without needing that code to also
+ * exercise libsodium.
+ */
+class Mock_Keyring implements WP_Secrets_Keyring {
+
+	const MARKER = 'mock-wrapped:';
+
+	/**
+	 * Whether wrap() should return WP_Error.
+	 *
+	 * @var bool
+	 */
+	private $fail_wrap = false;
+
+	/**
+	 * Whether unwrap() should return WP_Error.
+	 *
+	 * @var bool
+	 */
+	private $fail_unwrap = false;
+
+	/**
+	 * Number of times wrap() has been called.
+	 *
+	 * @var int
+	 *
+	 * @phpstan-var int<0, max>
+	 */
+	private $wrap_calls = 0;
+
+	/**
+	 * Number of times unwrap() has been called.
+	 *
+	 * @var int
+	 *
+	 * @phpstan-var int<0, max>
+	 */
+	private $unwrap_calls = 0;
+
+	public function wrap( string $key_material ) {
+		++$this->wrap_calls;
+
+		if ( $this->fail_wrap ) {
+			return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, 'Mock_Keyring: wrap() configured to fail.' );
+		}
+
+		$nonce = random_bytes( 8 );
+		$tag   = hash( 'sha256', $nonce . $key_material, true );
+
+		return self::MARKER . base64_encode( $nonce . $key_material . $tag );
+	}
+
+	public function unwrap( string $wrapped ) {
+		++$this->unwrap_calls;
+
+		if ( $this->fail_unwrap ) {
+			return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, 'Mock_Keyring: unwrap() configured to fail.' );
+		}
+
+		if ( 0 !== strpos( $wrapped, self::MARKER ) ) {
+			return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, 'Mock_Keyring: not a value this keyring wrapped.' );
+		}
+
+		$decoded = base64_decode( substr( $wrapped, strlen( self::MARKER ) ), true );
+
+		if ( false === $decoded || strlen( $decoded ) < 41 ) {
+			return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, 'Mock_Keyring: wrapped value is malformed.' );
+		}
+
+		$nonce        = substr( $decoded, 0, 8 );
+		$key_material = substr( $decoded, 8, -32 );
+		$tag          = substr( $decoded, -32 );
+
+		if ( ! hash_equals( hash( 'sha256', $nonce . $key_material, true ), $tag ) ) {
+			return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, 'Mock_Keyring: integrity tag mismatch.' );
+		}
+
+		return $key_material;
+	}
+
+	public function get_key_source(): string {
+		return 'mock keyring';
+	}
+
+	/**
+	 * @param bool $fail Whether wrap() should return WP_Error.
+	 *
+	 * @return $this
+	 */
+	public function configure_fail_wrap( bool $fail = true ): self {
+		$this->fail_wrap = $fail;
+
+		return $this;
+	}
+
+	/**
+	 * @param bool $fail Whether unwrap() should return WP_Error.
+	 *
+	 * @return $this
+	 */
+	public function configure_fail_unwrap( bool $fail = true ): self {
+		$this->fail_unwrap = $fail;
+
+		return $this;
+	}
+
+	/**
+	 * @return int Number of times wrap() has been called.
+	 *
+	 * @phpstan-return int<0, max>
+	 */
+	public function wrap_call_count(): int {
+		return $this->wrap_calls;
+	}
+
+	/**
+	 * @return int Number of times unwrap() has been called.
+	 *
+	 * @phpstan-return int<0, max>
+	 */
+	public function unwrap_call_count(): int {
+		return $this->unwrap_calls;
+	}
+}
Index: /trunk/tests/phpunit/includes/secrets/class-mock-store.php
===================================================================
--- /trunk/tests/phpunit/includes/secrets/class-mock-store.php	(revision 64337)
+++ /trunk/tests/phpunit/includes/secrets/class-mock-store.php	(revision 64337)
@@ -0,0 +1,116 @@
+<?php
+/**
+ * Configurable WP_Secrets_Store test double.
+ *
+ * Can be told to fail any operation on demand, and records every value it is ever
+ * handed so that a test can assert it was never given a plaintext.
+ */
+class Mock_Store implements WP_Secrets_Store {
+
+	/**
+	 * Stored records, keyed by scope and name.
+	 *
+	 * @var array<string, array<mixed>>
+	 */
+	private $records = array();
+
+	/**
+	 * Which operations are configured to fail.
+	 *
+	 * @var array<string, bool>
+	 */
+	private $fail = array(
+		'get'    => false,
+		'set'    => false,
+		'delete' => false,
+		'list'   => false,
+	);
+
+	/**
+	 * Every record ever passed to set(), in call order.
+	 *
+	 * @var array<int, array<mixed>>
+	 */
+	private $received_records = array();
+
+	public function get( string $name, bool $network = false ) {
+		if ( $this->fail['get'] ) {
+			return new WP_Error( WP_SECRETS_ERROR_STORE_UNAVAILABLE, 'Mock_Store: get() configured to fail.' );
+		}
+
+		$key = $this->key( $name, $network );
+
+		return array_key_exists( $key, $this->records ) ? $this->records[ $key ] : null;
+	}
+
+	public function set( string $name, array $record, bool $network = false ) {
+		$this->received_records[] = $record;
+
+		if ( $this->fail['set'] ) {
+			return new WP_Error( WP_SECRETS_ERROR_STORE_UNAVAILABLE, 'Mock_Store: set() configured to fail.' );
+		}
+
+		$this->records[ $this->key( $name, $network ) ] = $record;
+
+		return true;
+	}
+
+	public function delete( string $name, bool $network = false ) {
+		if ( $this->fail['delete'] ) {
+			return new WP_Error( WP_SECRETS_ERROR_STORE_UNAVAILABLE, 'Mock_Store: delete() configured to fail.' );
+		}
+
+		unset( $this->records[ $this->key( $name, $network ) ] );
+
+		return true;
+	}
+
+	public function list_names( bool $network = false ) {
+		if ( $this->fail['list'] ) {
+			return new WP_Error( WP_SECRETS_ERROR_STORE_UNAVAILABLE, 'Mock_Store: list_names() configured to fail.' );
+		}
+
+		$prefix = $network ? 'network:' : 'site:';
+		$names  = array();
+
+		foreach ( array_keys( $this->records ) as $key ) {
+			if ( 0 === strpos( $key, $prefix ) ) {
+				$names[] = substr( $key, strlen( $prefix ) );
+			}
+		}
+
+		return $names;
+	}
+
+	/**
+	 * @param string $operation One of 'get', 'set', 'delete', 'list'.
+	 * @param bool   $fail      Whether that operation should return WP_Error.
+	 *
+	 * @return $this
+	 */
+	public function configure_fail( string $operation, bool $fail = true ): self {
+		$this->fail[ $operation ] = $fail;
+
+		return $this;
+	}
+
+	/**
+	 * Every record ever passed to set(), in call order – including ones set()
+	 * went on to reject, since the assertion this exists for is "was this store
+	 * ever handed a plaintext," not "was a plaintext ever successfully stored."
+	 *
+	 * @return array<int, array<mixed>>
+	 */
+	public function get_received_records(): array {
+		return $this->received_records;
+	}
+
+	/**
+	 * @param string $name    The secret's namespaced name.
+	 * @param bool   $network Whether this is a network-scope secret.
+	 * @return string The key the record is held under.
+	 */
+	private function key( string $name, bool $network ): string {
+		return ( $network ? 'network:' : 'site:' ) . $name;
+	}
+}
Index: /trunk/tests/phpunit/includes/secrets/class-wp-secrets-keyring-conformance.php
===================================================================
--- /trunk/tests/phpunit/includes/secrets/class-wp-secrets-keyring-conformance.php	(revision 64337)
+++ /trunk/tests/phpunit/includes/secrets/class-wp-secrets-keyring-conformance.php	(revision 64337)
@@ -0,0 +1,116 @@
+<?php
+/**
+ * Conformance suite for WP_Secrets_Keyring implementations.
+ *
+ * Extend this, return a keyring from keyring(), and the contract every keyring is
+ * held to gets checked against it. "implements WP_Secrets_Keyring" is a claim PHP
+ * can verify about method names and nothing else – it cannot tell you that
+ * wrap() is non-deterministic, that unwrap() rejects tampering instead of
+ * silently returning garbage bytes, or that failure is reported as WP_Error
+ * rather than an exception or a falsy string. Those are the properties that
+ * actually matter to WP_Secrets_Key_Manager, the only caller of a keyring.
+ *
+ * @package SecretsAPI
+ */
+abstract class WP_Secrets_Keyring_Conformance extends WP_UnitTestCase {
+
+	/**
+	 * The keyring under test. Fresh per test.
+	 *
+	 * @return WP_Secrets_Keyring
+	 */
+	abstract protected function keyring();
+
+	/**
+	 * Wrap() followed by unwrap() has to return exactly the bytes that went in –
+	 * WP_Secrets_Key_Manager derives the root key from this round trip, so any
+	 * corruption here is a corrupted root key for every secret on the site.
+	 */
+	public function test_wrap_returns_a_non_empty_string_that_unwraps_to_the_same_bytes(): void {
+		$keyring      = $this->keyring();
+		$key_material = random_bytes( 32 );
+
+		$wrapped = $keyring->wrap( $key_material );
+
+		$this->assertIsString( $wrapped );
+		$this->assertNotSame( '', $wrapped );
+
+		$unwrapped = $keyring->unwrap( $wrapped );
+
+		$this->assertSame( $key_material, $unwrapped );
+	}
+
+	/**
+	 * A deterministic wrap() leaks, via ciphertext comparison, whether two wrapped
+	 * values protect the same key material – something nothing outside the
+	 * keyring is entitled to learn. A fresh nonce (or equivalent) per call is what
+	 * WP_Secrets_Key_Manager relies on to keep that comparison unavailable.
+	 */
+	public function test_two_wraps_of_the_same_bytes_return_different_strings(): void {
+		$keyring      = $this->keyring();
+		$key_material = random_bytes( 32 );
+
+		$first  = $keyring->wrap( $key_material );
+		$second = $keyring->wrap( $key_material );
+
+		$this->assertNotSame( $first, $second );
+	}
+
+	/**
+	 * Unwrap() never throws and never returns a plausible-looking string for input
+	 * it did not produce – WP_Secrets_Key_Manager treats anything other than
+	 * WP_Error as usable key material, so a keyring that returns garbage bytes on
+	 * garbage input hands a wrong root key downstream instead of failing.
+	 */
+	public function test_unwrap_of_garbage_is_a_wp_error(): void {
+		$keyring = $this->keyring();
+		$garbage = 'garbage-' . bin2hex( random_bytes( 16 ) );
+
+		$this->assertWPError( $keyring->unwrap( $garbage ) );
+	}
+
+	/**
+	 * A truncated wrapped value must fail closed rather than decode to a short,
+	 * wrong key – WP_Secrets_Key_Manager has no way to tell a merely-short key
+	 * from a correctly-derived one except by trusting unwrap()'s success.
+	 */
+	public function test_unwrap_of_a_truncated_value_is_a_wp_error(): void {
+		$keyring = $this->keyring();
+		$wrapped = $keyring->wrap( random_bytes( 32 ) );
+
+		$this->assertIsString( $wrapped );
+
+		$truncated = substr( $wrapped, 0, intdiv( strlen( $wrapped ), 2 ) );
+
+		$this->assertWPError( $keyring->unwrap( $truncated ) );
+	}
+
+	/**
+	 * A single flipped bit anywhere in the wrapped value has to be caught –
+	 * that is the entire point of authenticated wrapping. Silently accepting it
+	 * would let a corrupted or tampered wrapped root key through as genuine.
+	 */
+	public function test_unwrap_of_a_value_with_one_flipped_byte_is_a_wp_error(): void {
+		$keyring = $this->keyring();
+		$wrapped = $keyring->wrap( random_bytes( 32 ) );
+
+		$this->assertIsString( $wrapped );
+
+		$flip_at             = intdiv( strlen( $wrapped ), 2 );
+		$flipped             = $wrapped;
+		$flipped[ $flip_at ] = chr( ord( $wrapped[ $flip_at ] ) ^ 0x01 );
+
+		$this->assertWPError( $keyring->unwrap( $flipped ) );
+	}
+
+	/**
+	 * Site Health renders get_key_source() directly; an empty or non-string value
+	 * there is a blank line on a diagnostics page an operator is depending on.
+	 */
+	public function test_get_key_source_returns_a_non_empty_string(): void {
+		$source = $this->keyring()->get_key_source();
+
+		$this->assertIsString( $source );
+		$this->assertNotSame( '', trim( $source ) );
+	}
+}
Index: /trunk/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php
===================================================================
--- /trunk/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php	(revision 64337)
+++ /trunk/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php	(revision 64337)
@@ -0,0 +1,428 @@
+<?php
+/**
+ * Conformance suite for WP_Secrets_Provider implementations.
+ *
+ * Extend this, return a provider from provider(), and the contract every provider
+ * is held to gets checked against it. The point is that "implements
+ * WP_Secrets_Provider" is a claim PHP can verify about method names and nothing
+ * else – it cannot tell you that absence is reported as null rather than false, or
+ * that an unreachable backend fails closed instead of looking empty, and those are
+ * the properties that actually matter.
+ *
+ * A host writing a KMS-, HSM-, or platform-backed provider can run this against it
+ * before shipping. That is deliberate: this contract is currently shaped by three
+ * hosts *describing* what they need rather than by three working implementations,
+ * so an executable statement of it is worth more than another paragraph of prose.
+ *
+ * Where the contract legitimately varies, the suite adapts rather than insisting:
+ * a read-only provider is not asked to round-trip a value, and a provider with no
+ * version history is not asked to produce a previous one. What does not vary is
+ * checked for everyone.
+ *
+ * @package SecretsAPI
+ */
+abstract class WP_Secrets_Provider_Conformance extends WP_UnitTestCase {
+
+	/**
+	 * The provider under test. Fresh per test.
+	 *
+	 * @return WP_Secrets_Provider
+	 */
+	abstract protected function provider();
+
+	/**
+	 * A name this provider will accept, unique per test run.
+	 *
+	 * Overridable because a platform may have its own naming rules – a provider
+	 * backed by an AWS Parameter Store path, say, is entitled to want something
+	 * that looks like a path.
+	 *
+	 * @return string
+	 */
+	protected function conformance_name() {
+		return 'conformance/subject';
+	}
+
+	/**
+	 * Skips the current test when the provider does not accept writes.
+	 *
+	 * Read-only is a legitimate shape – it is the whole reason is_writable()
+	 * exists – so the round-trip checks below do not apply to it. They are
+	 * skipped rather than quietly passing, so the report says what was not proven.
+	 *
+	 * @param WP_Secrets_Provider $provider The provider under test.
+	 */
+	private function require_writable( WP_Secrets_Provider $provider ): void {
+		if ( ! $provider->is_writable() ) {
+			$this->markTestSkipped( 'Provider declares itself read-only; write-path conformance does not apply.' );
+		}
+	}
+
+	/**
+	 * Every `wp_secret_changed` call seen since record_changes(), as its arguments.
+	 *
+	 * @var array<int, array<int, mixed>>
+	 */
+	private $recorded_changes = array();
+
+	/**
+	 * Starts recording every `wp_secret_changed` call for the rest of the test.
+	 *
+	 * Asks for all seven arguments, so a provider that passes fewer is caught by
+	 * the count rather than by a missing-argument error.
+	 */
+	private function record_changes(): void {
+		$this->recorded_changes = array();
+
+		add_action(
+			'wp_secret_changed',
+			function ( ...$args ): void {
+				$this->recorded_changes[] = array_values( $args );
+			},
+			10,
+			7
+		);
+	}
+
+	/**
+	 * Whether the provider currently returns a previous version for a name.
+	 *
+	 * Asked before and after retiring, and the answer is expected to differ, so
+	 * this is marked impure to stop static analysis assuming the two agree.
+	 *
+	 * @phpstan-impure
+	 *
+	 * @param WP_Secrets_Provider $provider The provider under test.
+	 * @param string              $name     The secret's name.
+	 * @return bool
+	 */
+	private function has_previous_version( WP_Secrets_Provider $provider, $name ) {
+		return $provider->get( $name, WP_Secret_Version::PREVIOUS ) instanceof WP_Secret;
+	}
+
+	// – declarations must be coherent ----------------------------------------
+
+	public function test_reports_a_non_empty_label(): void {
+		$label = $this->provider()->get_label();
+
+		$this->assertIsString( $label );
+		$this->assertNotSame( '', trim( $label ) );
+	}
+
+	public function test_reports_a_known_protection_boundary(): void {
+		$this->assertContains(
+			$this->provider()->get_protection_boundary(),
+			array( WP_Secrets_Provider::BOUNDARY_WORDPRESS, WP_Secrets_Provider::BOUNDARY_PROVIDER ),
+			'get_protection_boundary() must return one of the BOUNDARY_* constants.'
+		);
+	}
+
+	public function test_is_writable_returns_a_bool(): void {
+		$this->assertIsBool( $this->provider()->is_writable() );
+	}
+
+	/**
+	 * The declaration has to match the behaviour, or a settings screen that trusts
+	 * is_writable() will offer a save control that cannot work.
+	 */
+	public function test_a_read_only_provider_actually_refuses_writes(): void {
+		$provider = $this->provider();
+
+		if ( $provider->is_writable() ) {
+			$this->markTestSkipped( 'Provider accepts writes; this checks the read-only declaration.' );
+		}
+
+		$result = $provider->set( $this->conformance_name(), 'value' );
+
+		$this->assertWPError( $result );
+		$this->assertSame(
+			WP_SECRETS_ERROR_PROVIDER_READ_ONLY,
+			$result->get_error_code(),
+			'A provider that declares itself read-only must refuse with secret_provider_read_only.'
+		);
+	}
+
+	// – absence is null, and only null ---------------------------------------
+
+	/**
+	 * The single most important property in the whole API. A name that was never
+	 * set is null – not false, not an empty WP_Secret, not a WP_Error. Reporting
+	 * absence as an error makes every caller treat a missing optional credential
+	 * as an outage; reporting an outage as absence makes them treat it as deleted.
+	 */
+	public function test_a_name_that_was_never_set_is_null(): void {
+		$result = $this->provider()->get( 'conformance/never-set-' . uniqid(), WP_Secret_Version::CURRENT );
+
+		$this->assertNull( $result );
+	}
+
+	/**
+	 * A provider with no version history reports absence for PREVIOUS, because
+	 * that is what it is: there is no previous value. It is not an error, and it
+	 * is not an excuse to return the current one.
+	 */
+	public function test_previous_on_a_provider_or_secret_without_history_is_null_not_error(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$name = $this->conformance_name();
+		$this->assertNotWPError( $provider->set( $name, 'only-ever-one-value' ) );
+
+		$previous = $provider->get( $name, WP_Secret_Version::PREVIOUS );
+
+		$this->assertNotWPError( $previous, 'PREVIOUS with no previous value is absence, not an error.' );
+		$this->assertNull( $previous );
+	}
+
+	public function test_deleting_something_absent_is_success(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$this->assertNotWPError(
+			$provider->delete( 'conformance/never-set-' . uniqid() ),
+			'Deleting a secret that does not exist is the state the caller asked for.'
+		);
+	}
+
+	public function test_retiring_with_no_previous_version_is_success(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$name = $this->conformance_name();
+		$provider->set( $name, 'value' );
+
+		$this->assertNotWPError( $provider->retire_previous( $name ) );
+	}
+
+	// – the round trip --------------------------------------------------------
+
+	public function test_a_stored_value_comes_back_intact(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$name = $this->conformance_name();
+		$this->assertNotWPError( $provider->set( $name, 'sk_live_conformance_value' ) );
+
+		$secret = $provider->get( $name, WP_Secret_Version::CURRENT );
+
+		$this->assertInstanceOf( 'WP_Secret', $secret );
+		$this->assertSame( 'sk_live_conformance_value', $secret->reveal() );
+		$this->assertSame( $name, $secret->get_name() );
+	}
+
+	/**
+	 * Fingerprints are how callers compare secrets without revealing them, so the
+	 * same value must fingerprint the same way twice. A provider that returns a
+	 * random or timestamped fingerprint breaks every comparison built on it.
+	 */
+	public function test_a_fingerprint_is_stable_for_the_same_value(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$name = $this->conformance_name();
+		$provider->set( $name, 'stable-value' );
+
+		$first_read  = $provider->get( $name, WP_Secret_Version::CURRENT );
+		$second_read = $provider->get( $name, WP_Secret_Version::CURRENT );
+
+		$this->assertInstanceOf( 'WP_Secret', $first_read );
+		$this->assertInstanceOf( 'WP_Secret', $second_read );
+
+		$first  = $first_read->fingerprint();
+		$second = $second_read->fingerprint();
+
+		$this->assertIsString( $first );
+		$this->assertNotSame( '', $first );
+		$this->assertSame( $first, $second );
+	}
+
+	public function test_a_deleted_secret_reads_as_absent(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$name = $this->conformance_name();
+		$provider->set( $name, 'value' );
+		$this->assertNotWPError( $provider->delete( $name ) );
+
+		$this->assertNull( $provider->get( $name, WP_Secret_Version::CURRENT ) );
+	}
+
+	// – every change is reported ----------------------------------------------
+
+	/**
+	 * The API does not fire `wp_secret_changed` on a provider's behalf, so a
+	 * provider that forgets to is a site whose audit log went quiet the day the
+	 * host installed it. Nothing else would notice.
+	 */
+	public function test_a_successful_write_reports_a_change(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$name = $this->conformance_name();
+		$this->record_changes();
+
+		$this->assertNotWPError( $provider->set( $name, 'UNIQUE-CONFORMANCE-CANARY-7c1d' ) );
+
+		$this->assertCount( 1, $this->recorded_changes, 'set() must fire wp_secret_changed exactly once.' );
+
+		$change = $this->recorded_changes[0];
+
+		$this->assertCount( 7, $change, 'wp_secret_changed takes seven arguments, ending with $network.' );
+		$this->assertSame( $name, $change[0] );
+		$this->assertContains( $change[1], array( 'created', 'updated' ) );
+		$this->assertIsInt( $change[2], 'The actor is a user id, or 0.' );
+		$this->assertIsInt( $change[3], 'The timestamp is a Unix timestamp.' );
+		$this->assertIsString( $change[4], "The old fingerprint is a string, '' when there is none." );
+		$this->assertIsString( $change[5], "The new fingerprint is a string, '' when there is none." );
+		$this->assertFalse( $change[6], 'A site-scope write must report $network as false.' );
+		$this->assertNotContains( 'UNIQUE-CONFORMANCE-CANARY-7c1d', $change, 'wp_secret_changed must never be passed a value.' );
+	}
+
+	public function test_an_action_override_is_reported_as_given(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$this->record_changes();
+
+		$this->assertNotWPError( $provider->set( $this->conformance_name(), 'value', false, false, 'imported' ) );
+
+		$this->assertCount( 1, $this->recorded_changes );
+		$this->assertSame( 'imported', $this->recorded_changes[0][1] );
+	}
+
+	/**
+	 * A listener cannot otherwise tell a network secret from a site secret of the
+	 * same name, and on a network those are different credentials.
+	 */
+	public function test_a_network_scope_change_reports_its_scope(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$name = $this->conformance_name();
+		$this->record_changes();
+
+		$this->assertNotWPError( $provider->set( $name, 'value', true ) );
+		$this->assertNotWPError( $provider->delete( $name, true ) );
+
+		$this->assertCount( 2, $this->recorded_changes );
+		$this->assertTrue( $this->recorded_changes[0][6], 'A network-scope write must report $network as true.' );
+		$this->assertTrue( $this->recorded_changes[1][6], 'A network-scope delete must report $network as true.' );
+	}
+
+	public function test_deleting_a_secret_reports_a_change(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$name = $this->conformance_name();
+		$provider->set( $name, 'value' );
+
+		$this->record_changes();
+
+		$this->assertNotWPError( $provider->delete( $name ) );
+
+		$this->assertCount( 1, $this->recorded_changes, 'delete() must fire wp_secret_changed exactly once.' );
+		$this->assertSame( $name, $this->recorded_changes[0][0] );
+		$this->assertSame( 'deleted', $this->recorded_changes[0][1] );
+	}
+
+	/**
+	 * Only asked of a provider that keeps a previous version and clears it on
+	 * request. One with no history, or whose backend manages its own, has nothing
+	 * to report.
+	 */
+	public function test_retiring_a_previous_version_reports_a_change(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$name = $this->conformance_name();
+		$provider->set( $name, 'first-value' );
+		$provider->set( $name, 'second-value' );
+
+		if ( ! $this->has_previous_version( $provider, $name ) ) {
+			$this->markTestSkipped( 'Provider keeps no previous version; there is nothing to retire.' );
+		}
+
+		$this->record_changes();
+
+		$this->assertNotWPError( $provider->retire_previous( $name ) );
+
+		if ( $this->has_previous_version( $provider, $name ) ) {
+			$this->markTestSkipped( 'Provider leaves version history to its backend; nothing was cleared.' );
+		}
+
+		$this->assertCount( 1, $this->recorded_changes, 'retire_previous() must fire wp_secret_changed exactly once.' );
+		$this->assertSame( $name, $this->recorded_changes[0][0] );
+		$this->assertSame( 'retired', $this->recorded_changes[0][1] );
+	}
+
+	public function test_a_refused_write_reports_no_change(): void {
+		$provider = $this->provider();
+
+		if ( $provider->is_writable() ) {
+			$this->markTestSkipped( 'Provider accepts writes; this checks that a refused one stays silent.' );
+		}
+
+		$this->record_changes();
+
+		$this->assertWPError( $provider->set( $this->conformance_name(), 'value' ) );
+		$this->assertCount( 0, $this->recorded_changes, 'A write that did not happen must not be reported as a change.' );
+	}
+
+	// – listing never leaks ---------------------------------------------------
+
+	public function test_listing_returns_an_array_or_an_error_and_never_a_value(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$name = $this->conformance_name();
+		$provider->set( $name, 'UNIQUE-CONFORMANCE-CANARY-3f9b' );
+
+		$entries = $provider->list_secrets();
+
+		$this->assertIsArray( $entries );
+
+		$dump = wp_json_encode( $entries );
+
+		$this->assertIsString( $dump, 'list_secrets() must return something that can be encoded and inspected.' );
+		$this->assertStringNotContainsString(
+			'UNIQUE-CONFORMANCE-CANARY-3f9b',
+			$dump,
+			'list_secrets() must never expose a plaintext.'
+		);
+	}
+
+	public function test_listing_includes_a_stored_secret(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$name = $this->conformance_name();
+		$provider->set( $name, 'value' );
+
+		$entries = $provider->list_secrets();
+
+		$this->assertNotWPError( $entries );
+		$this->assertContains( $name, wp_list_pluck( $entries, 'name' ) );
+	}
+
+	/**
+	 * A prefix filter that matched loosely would hand a caller names it did not
+	 * ask about, which for a listing keyed by owner is exactly the wrong direction
+	 * to be wrong in.
+	 */
+	public function test_listing_respects_a_name_prefix(): void {
+		$provider = $this->provider();
+		$this->require_writable( $provider );
+
+		$provider->set( 'conformance-a/one', 'value' );
+		$provider->set( 'conformance-b/two', 'value' );
+
+		$entries = $provider->list_secrets( 'conformance-a' );
+
+		$this->assertNotWPError( $entries );
+
+		$names = wp_list_pluck( $entries, 'name' );
+
+		$this->assertContains( 'conformance-a/one', $names );
+		$this->assertNotContains( 'conformance-b/two', $names );
+	}
+}
Index: /trunk/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php
===================================================================
--- /trunk/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php	(revision 64337)
+++ /trunk/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php	(revision 64337)
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Shared assertions for the Secrets API test suite.
+ *
+ * Kept in one place so a change to what "never contains plaintext" means is a single
+ * edit rather than a search across the suite.
+ *
+ * @package SecretsAPI
+ */
+
+/**
+ * Assertions shared across the Secrets API test suite.
+ */
+trait WP_Secrets_Assertions {
+
+	/**
+	 * Assert that a value is a WP_Secret, and optionally that it reveals a specific
+	 * plaintext.
+	 *
+	 * Checks WP_Error explicitly first so a failure reports the actual error code
+	 * rather than the far less useful "WP_Error is not an instance of WP_Secret".
+	 *
+	 * @param mixed       $maybe_secret Value under test.
+	 * @param string|null $expected     Expected plaintext, or null to skip that check.
+	 * @param string      $message      Optional failure message.
+	 */
+	public function assertIsSecret( $maybe_secret, $expected = null, $message = '' ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- PHPUnit assertion naming, like assertWPError().
+		$context = '' !== $message ? $message . ' ' : '';
+
+		if ( is_wp_error( $maybe_secret ) ) {
+			$this->fail( $context . 'Expected a WP_Secret, got WP_Error: ' . $maybe_secret->get_error_code() . ': ' . $maybe_secret->get_error_message() );
+		}
+
+		$this->assertInstanceOf(
+			WP_Secret::class,
+			$maybe_secret,
+			$context . 'Expected a WP_Secret, got ' . ( is_object( $maybe_secret ) ? get_class( $maybe_secret ) : gettype( $maybe_secret ) ) . '.'
+		);
+
+		if ( null !== $expected ) {
+			$this->assertSame( $expected, $maybe_secret->reveal(), $context . 'WP_Secret revealed the wrong plaintext.' );
+		}
+	}
+
+	/**
+	 * Assert that a stored record's slot decrypts to an expected plaintext, going
+	 * through the public retrieval path.
+	 *
+	 * @param string $name     The secret's namespaced name.
+	 * @param string $slot     A WP_Secret_Version constant.
+	 * @param string $expected Expected plaintext.
+	 * @param bool   $network  Whether this is a network-scope secret.
+	 *
+	 * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot
+	 */
+	public function assertRecordSlotDecryptsTo( $name, $slot, $expected, $network = false ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- PHPUnit assertion naming, like assertWPError().
+		$secret = $network ? wp_get_network_secret( $name, $slot ) : wp_get_secret( $name, $slot );
+
+		$this->assertIsSecret( $secret, $expected, sprintf( 'Slot "%s" of "%s":', $slot, $name ) );
+	}
+
+	/**
+	 * Assert that a haystack of any shape contains no trace of a plaintext value.
+	 *
+	 * Arrays and objects are walked recursively and serialized defensively, because
+	 * the failure mode this guards against is a plaintext surviving somewhere nested
+	 * that a shallow string comparison would miss.
+	 *
+	 * @param string $plaintext Value that must not appear.
+	 * @param mixed  $haystack  Structure to search.
+	 * @param string $message   Optional failure message.
+	 */
+	public function assertNeverContainsPlaintext( $plaintext, $haystack, $message = '' ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- PHPUnit assertion naming, like assertWPError().
+		$this->assertNotSame( '', $plaintext, 'Refusing to search for an empty plaintext; the assertion would be vacuous.' );
+
+		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- Flattening arbitrary structures is the point of this assertion; print_r reaches nested values that a shallow comparison would miss.
+		$flattened = is_scalar( $haystack ) ? (string) $haystack : print_r( $haystack, true );
+
+		$this->assertStringNotContainsString(
+			$plaintext,
+			$flattened,
+			'' !== $message ? $message : 'Plaintext leaked into a structure that must never contain it.'
+		);
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/brokenDropinFallbacks.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/brokenDropinFallbacks.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/brokenDropinFallbacks.php	(revision 64337)
@@ -0,0 +1,78 @@
+<?php
+/**
+ * Direct tests for WP_Secrets_Broken_Store and WP_Secrets_Broken_Keyring: the
+ * sentinels _wp_secrets_get_store()/_wp_secrets_get_key_manager() fall back to when
+ * a secrets.php drop-in exists but left an invalid override.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_BrokenDropinFallbacks extends WP_UnitTestCase {
+
+	public function test_broken_store_implements_the_interface(): void {
+		$this->assertInstanceOf( WP_Secrets_Store::class, new WP_Secrets_Broken_Store() ); // @phpstan-ignore method.alreadyNarrowedType (Intentionally checking the class against its interface at runtime.)
+	}
+
+	public function test_broken_store_get_fails(): void {
+		$result = ( new WP_Secrets_Broken_Store() )->get( 'myplugin/api-key' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code() );
+	}
+
+	public function test_broken_store_set_fails(): void {
+		$result = ( new WP_Secrets_Broken_Store() )->set( 'myplugin/api-key', array() );
+
+		$this->assertWPError( $result );
+	}
+
+	public function test_broken_store_delete_fails(): void {
+		$result = ( new WP_Secrets_Broken_Store() )->delete( 'myplugin/api-key' );
+
+		$this->assertWPError( $result );
+	}
+
+	public function test_broken_store_list_names_fails(): void {
+		$result = ( new WP_Secrets_Broken_Store() )->list_names();
+
+		$this->assertWPError( $result );
+	}
+
+
+	public function test_broken_keyring_implements_the_interface(): void {
+		$this->assertInstanceOf( WP_Secrets_Keyring::class, new WP_Secrets_Broken_Keyring() ); // @phpstan-ignore method.alreadyNarrowedType (Intentionally checking the class against its interface at runtime.)
+	}
+
+	public function test_broken_keyring_wrap_fails(): void {
+		$result = ( new WP_Secrets_Broken_Keyring() )->wrap( 'material' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() );
+	}
+
+	public function test_broken_keyring_unwrap_fails(): void {
+		$result = ( new WP_Secrets_Broken_Keyring() )->unwrap( 'wrapped' );
+
+		$this->assertWPError( $result );
+	}
+
+	public function test_broken_keyring_reports_a_key_source(): void {
+		$this->assertIsString( ( new WP_Secrets_Broken_Keyring() )->get_key_source() );
+	}
+
+	public function test_wp_using_secrets_dropin_is_false_by_default(): void {
+		$this->assertFalse( wp_using_secrets_dropin() );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_wp_using_secrets_dropin_reports_true_once_the_loader_flag_is_set(): void {
+		// Simulates what wp_load_secrets_dropin() sets when a drop-in file is
+		// found, without depending on an actual file on disk – see
+		// tests/phpunit/test-secrets-extension-points.php for why.
+		$GLOBALS['wp_secrets_dropin_loaded'] = true;
+
+		$this->assertTrue( wp_using_secrets_dropin() );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/capabilities.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/capabilities.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/capabilities.php	(revision 64337)
@@ -0,0 +1,133 @@
+<?php
+/**
+ * Tests for the Secrets API's two capabilities: manage_secrets, granted to
+ * administrators by populate_roles() and upgrade_720(), and manage_network_secrets,
+ * held only by super admins.
+ *
+ * @group secrets
+ * @group capabilities
+ */
+class Tests_Secrets_Capabilities extends WP_UnitTestCase {
+
+	public static function set_up_before_class() {
+		parent::set_up_before_class();
+
+		require_once ABSPATH . 'wp-admin/includes/schema.php';
+		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+	}
+
+	public function tear_down() {
+		$administrator = get_role( 'administrator' );
+
+		if ( $administrator ) {
+			$administrator->add_cap( WP_SECRETS_CAP_MANAGE );
+		}
+
+		parent::tear_down();
+	}
+
+	/**
+	 * Returns a role that must exist, failing the test if it does not.
+	 *
+	 * @param string $role Role name.
+	 * @return WP_Role
+	 */
+	private function role( $role ) {
+		$role_object = get_role( $role );
+
+		$this->assertInstanceOf( WP_Role::class, $role_object, "The \"{$role}\" role does not exist." );
+
+		return $role_object;
+	}
+
+	public function test_capability_constant_values(): void {
+		$this->assertSame( 'manage_secrets', WP_SECRETS_CAP_MANAGE ); // @phpstan-ignore method.alreadyNarrowedType (Intentionally pinning the value of a public constant.)
+		$this->assertSame( 'manage_network_secrets', WP_SECRETS_CAP_MANAGE_NETWORK ); // @phpstan-ignore method.alreadyNarrowedType (Intentionally pinning the value of a public constant.)
+	}
+
+	public function test_a_fresh_install_grants_manage_secrets_to_administrator(): void {
+		$this->assertTrue( $this->role( 'administrator' )->has_cap( WP_SECRETS_CAP_MANAGE ) );
+	}
+
+	public function test_no_other_role_is_granted_manage_secrets(): void {
+		foreach ( array( 'editor', 'author', 'contributor', 'subscriber' ) as $role ) {
+			$this->assertFalse( $this->role( $role )->has_cap( WP_SECRETS_CAP_MANAGE ), $role );
+		}
+	}
+
+	public function test_populate_roles_720_grants_manage_secrets_to_administrator(): void {
+		$this->role( 'administrator' )->remove_cap( WP_SECRETS_CAP_MANAGE );
+
+		populate_roles_720();
+
+		$this->assertTrue( $this->role( 'administrator' )->has_cap( WP_SECRETS_CAP_MANAGE ) );
+	}
+
+	/**
+	 * @dataProvider data_upgrade_720_gate
+	 *
+	 * @param int  $db_version     The database version being upgraded from.
+	 * @param bool $expect_granted Whether the upgrade should grant the capability.
+	 */
+	public function test_upgrade_720_grants_manage_secrets_below_its_db_version( $db_version, $expect_granted ): void {
+		global $wp_current_db_version;
+
+		$saved                 = $wp_current_db_version;
+		$wp_current_db_version = $db_version;
+
+		$this->role( 'administrator' )->remove_cap( WP_SECRETS_CAP_MANAGE );
+
+		upgrade_720();
+
+		$wp_current_db_version = $saved;
+
+		$this->assertSame( $expect_granted, $this->role( 'administrator' )->has_cap( WP_SECRETS_CAP_MANAGE ) );
+	}
+
+	/**
+	 * Data provider.
+	 *
+	 * @return array<string, array{0: int, 1: bool}>
+	 */
+	public function data_upgrade_720_gate() {
+		return array(
+			'upgrading from 7.0'              => array( 61644, true ),
+			'upgrading from just below 7.2'   => array( 64336, true ),
+			'already at the 7.2 db version'   => array( 64337, false ),
+			'already past the 7.2 db version' => array( 64400, false ),
+		);
+	}
+
+	public function test_administrator_user_has_manage_secrets(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+
+		$this->assertTrue( user_can( $user_id, WP_SECRETS_CAP_MANAGE ) );
+	}
+
+	public function test_subscriber_does_not_have_manage_secrets(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+
+		$this->assertFalse( user_can( $user_id, WP_SECRETS_CAP_MANAGE ) );
+	}
+
+	/**
+	 * @group ms-excluded
+	 */
+	public function test_single_site_administrator_does_not_have_manage_network_secrets(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+
+		$this->assertFalse( user_can( $user_id, WP_SECRETS_CAP_MANAGE_NETWORK ) );
+	}
+
+	/**
+	 * @group ms-required
+	 */
+	public function test_only_super_admins_have_manage_network_secrets(): void {
+		$administrator = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		$super_admin   = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		grant_super_admin( $super_admin );
+
+		$this->assertFalse( user_can( $administrator, WP_SECRETS_CAP_MANAGE_NETWORK ) );
+		$this->assertTrue( user_can( $super_admin, WP_SECRETS_CAP_MANAGE_NETWORK ) );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/configKeyringConformance.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/configKeyringConformance.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/configKeyringConformance.php	(revision 64337)
@@ -0,0 +1,17 @@
+<?php
+/**
+ * Runs the keyring conformance suite against the keyring WordPress ships.
+ *
+ * The shipped keyring passing is what makes the suite trustworthy: a conformance
+ * kit nobody has run against a working implementation is a wish list. This is the
+ * known-good subject a host writing a KMS- or HSM-backed keyring can compare
+ * their own failures against.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_ConfigKeyringConformance extends WP_Secrets_Keyring_Conformance {
+
+	protected function keyring() {
+		return new WP_Secrets_Config_Key_Provider();
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/errorCodes.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/errorCodes.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/errorCodes.php	(revision 64337)
@@ -0,0 +1,54 @@
+<?php
+/**
+ * Pins the exact error-code strings so a later commit can't accidentally rename
+ * one – these are part of the API's public error contract.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_ErrorCodes extends WP_UnitTestCase {
+
+	/**
+	 * @dataProvider data_published_error_codes
+	 *
+	 * @param string $constant Name of the error-code constant.
+	 * @param string $expected Value the published contract assigns to it.
+	 */
+	public function test_error_code_values_match_the_published_contract( $constant, $expected ): void {
+		$this->assertSame( $expected, constant( $constant ) );
+	}
+
+	/**
+	 * @return array<string, array{0: string, 1: string}>
+	 */
+	public function data_published_error_codes() {
+		return array(
+			'decryption failed'  => array( 'WP_SECRETS_ERROR_DECRYPTION_FAILED', 'secret_decryption_failed' ),
+			'key unavailable'    => array( 'WP_SECRETS_ERROR_KEY_UNAVAILABLE', 'secret_key_unavailable' ),
+			'store unavailable'  => array( 'WP_SECRETS_ERROR_STORE_UNAVAILABLE', 'secret_store_unavailable' ),
+			'invalid name'       => array( 'WP_SECRETS_ERROR_INVALID_NAME', 'secret_invalid_name' ),
+			'invalid value'      => array( 'WP_SECRETS_ERROR_INVALID_VALUE', 'secret_invalid_value' ),
+			'crypto unavailable' => array( 'WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE', 'secret_crypto_unavailable' ),
+			'record malformed'   => array( 'WP_SECRETS_ERROR_RECORD_MALFORMED', 'secret_record_malformed' ),
+		);
+	}
+
+	public function test_all_error_codes_are_distinct(): void {
+		$codes = array(
+			WP_SECRETS_ERROR_DECRYPTION_FAILED,
+			WP_SECRETS_ERROR_KEY_UNAVAILABLE,
+			WP_SECRETS_ERROR_STORE_UNAVAILABLE,
+			WP_SECRETS_ERROR_INVALID_NAME,
+			WP_SECRETS_ERROR_INVALID_VALUE,
+			WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE,
+			WP_SECRETS_ERROR_RECORD_MALFORMED,
+		);
+
+		$this->assertSame( count( $codes ), count( array_unique( $codes ) ) );
+	}
+
+	public function test_max_name_length_matches_the_options_column_budget(): void {
+		// wp_options.option_name is VARCHAR(191); '_wp_network_secret_' (19
+		// characters) is the longest prefix any store here uses.
+		$this->assertSame( 191 - strlen( '_wp_network_secret_' ), WP_SECRETS_MAX_NAME_LENGTH );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/extensionPoints.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/extensionPoints.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/extensionPoints.php	(revision 64337)
@@ -0,0 +1,203 @@
+<?php
+/**
+ * Extension point tests: a plugin-supplied store or
+ * keyring must actually be used, both independently and in combination, and
+ * failure anywhere in either must fail closed rather than falling back to local
+ * defaults.
+ *
+ * Every test here injects its override via $GLOBALS['wp_secrets_store'] /
+ * $GLOBALS['wp_secrets_keyring'] – the same globals a secrets.php drop-in would
+ * set – rather than actually placing a drop-in file on disk. _wp_secrets_get_store()
+ * and _wp_secrets_get_key_manager() cache what they resolve to in a static local the
+ * first time either is called in a process, and by the time any test method runs,
+ * dozens of earlier tests have already triggered that caching with the defaults.
+ *
+ * @runInSeparateProcess gives each test a fresh process with nothing cached yet, so
+ * setting the global before the first call actually takes effect. This tests the
+ * real consumption logic in _wp_secrets_get_store()/_wp_secrets_get_key_manager();
+ * see docs/journal/test-coverage-gaps.md for what this does not cover (the drop-in's own file
+ * loading, which cannot be driven the same way).
+ *
+ * @group secrets
+ */
+class Tests_Secrets_ExtensionPoints extends WP_UnitTestCase {
+
+	use WP_Secrets_Assertions;
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_mock_store_with_the_default_keyring_round_trips(): void {
+		// Isolated, so this test supplies the site key rather than relying on the test config's salts.
+		define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'k', 32 ) ) );
+
+		$GLOBALS['wp_secrets_store'] = new Mock_Store();
+
+		$this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) );
+
+		$secret = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+		$this->assertSame( 'value', $secret->reveal() );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_mock_keyring_with_the_default_store_round_trips(): void {
+		$GLOBALS['wp_secrets_keyring'] = new Mock_Keyring();
+
+		$this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) );
+
+		$secret = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+		$this->assertSame( 'value', $secret->reveal() );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_mock_store_and_mock_keyring_together_round_trip(): void {
+		$GLOBALS['wp_secrets_store']   = new Mock_Store();
+		$GLOBALS['wp_secrets_keyring'] = new Mock_Keyring();
+
+		$this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) );
+
+		$secret = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+		$this->assertSame( 'value', $secret->reveal() );
+	}
+
+	/**
+	 * A store that can read but not write: an existing value seeded before write
+	 * support is turned off must remain readable, while a new write is rejected.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_read_succeeds_while_write_fails(): void {
+		// Isolated, so this test supplies the site key rather than relying on the test config's salts.
+		define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'k', 32 ) ) );
+
+		$store                       = new Mock_Store();
+		$GLOBALS['wp_secrets_store'] = $store;
+
+		wp_set_secret( 'myplugin/api-key', 'seeded-before-write-broke' );
+
+		$store->configure_fail( 'set', true );
+
+		$write_result = wp_set_secret( 'myplugin/api-key', 'new-value' );
+		$read_result  = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertWPError( $write_result );
+		$this->assertInstanceOf( WP_Secret::class, $read_result );
+		$this->assertSame( 'seeded-before-write-broke', $read_result->reveal() );
+	}
+
+
+	/**
+	 * A store that refuses writes must still serve reads. This previously configured
+	 * a capability flag that nothing consulted, so it passed regardless of behavior.
+	 * It now makes set() actually fail, and checks both halves.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_a_store_that_refuses_writes_still_serves_reads(): void {
+		// Isolated, so this test supplies the site key rather than relying on the test config's salts.
+		define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'k', 32 ) ) );
+
+		$store                       = new Mock_Store();
+		$GLOBALS['wp_secrets_store'] = $store;
+
+		wp_set_secret( 'myplugin/api-key', 'value' );
+		$store->configure_fail( 'set', true );
+
+		$this->assertWPError( wp_set_secret( 'myplugin/api-key', 'another' ) );
+
+		$secret = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+		$this->assertSame( 'value', $secret->reveal() );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_unreachable_store_fails_closed_with_no_local_fallback(): void {
+		$store                       = new Mock_Store();
+		$GLOBALS['wp_secrets_store'] = $store->configure_fail( 'get', true )->configure_fail( 'set', true );
+
+		$set_result = wp_set_secret( 'myplugin/api-key', 'value' );
+		$get_result = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertWPError( $set_result );
+		$this->assertWPError( $get_result );
+
+		// Fails closed, not merely differently: nothing was written to the real
+		// default store either, which a silent fallback would have done.
+		$this->assertFalse( get_option( '_wp_secret_myplugin/api-key' ) );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_unreachable_keyring_fails_closed_with_no_local_fallback(): void {
+		$GLOBALS['wp_secrets_keyring'] = ( new Mock_Keyring() )->configure_fail_wrap( true );
+
+		$result = wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$this->assertWPError( $result );
+		$this->assertFalse( get_option( '_wp_secret_myplugin/api-key' ) );
+	}
+
+	/**
+	 * The store must never be handed a plaintext, under any circumstance – this is
+	 * the whole point of the envelope. Mock_Store records every record it is
+	 * given; this asserts the plaintext appears nowhere in any of them.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_the_store_is_never_handed_a_plaintext(): void {
+		$store                       = new Mock_Store();
+		$GLOBALS['wp_secrets_store'] = $store;
+
+		wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' );
+
+		$this->assertNeverContainsPlaintext(
+			'UNIQUE-PLAINTEXT-CANARY-9f3a',
+			$store->get_received_records(),
+			'The store was handed a plaintext.'
+		);
+	}
+
+	/**
+	 * Simulates the state a broken secrets.php drop-in leaves behind
+	 * ($GLOBALS['wp_secrets_dropin_broken'] set by wp_load_secrets_dropin() –
+	 * see that function's docblock in secrets-api.php for why the drop-in's own file
+	 * loading cannot be driven the same way this simulates its aftermath) and
+	 * confirms every operation fails closed with WP_Error rather than a fatal error
+	 * or a silent fallback to local storage.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_a_broken_dropin_state_fails_every_operation_closed(): void {
+		$GLOBALS['wp_secrets_dropin_broken'] = true;
+
+		$this->assertWPError( wp_set_secret( 'myplugin/api-key', 'value' ) );
+		$this->assertWPError( wp_get_secret( 'myplugin/api-key' ) );
+		$this->assertWPError( wp_delete_secret( 'myplugin/api-key' ) );
+		$this->assertWPError( wp_list_secrets() );
+		$this->assertWPError( wp_retire_secret_version( 'myplugin/api-key' ) );
+		$this->assertFalse( wp_secrets_provider_is_writable() );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/mockKeyringConformance.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/mockKeyringConformance.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/mockKeyringConformance.php	(revision 64337)
@@ -0,0 +1,17 @@
+<?php
+/**
+ * Runs the keyring conformance suite against Mock_Keyring.
+ *
+ * Every other test in this suite treats Mock_Keyring as a stand-in for a real
+ * keyring; this proves that stand-in is never weaker than the contract it
+ * substitutes for, so a test that passes with Mock_Keyring in place is a test
+ * that would also have to reckon with a conforming real keyring.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_MockKeyringConformance extends WP_Secrets_Keyring_Conformance {
+
+	protected function keyring() {
+		return new Mock_Keyring();
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/networkScope.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/networkScope.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/networkScope.php	(revision 64337)
@@ -0,0 +1,169 @@
+<?php
+/**
+ * Tests for the network-scope public functions.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_NetworkScope extends WP_UnitTestCase {
+
+	use WP_Secrets_Assertions;
+
+	public function test_set_then_get_round_trips(): void {
+		$this->assertTrue( wp_set_network_secret( 'myplugin/api-key', 'value' ) );
+
+		$secret = wp_get_network_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+		$this->assertSame( 'value', $secret->reveal() );
+	}
+
+	public function test_get_returns_null_for_an_absent_secret(): void {
+		$this->assertNull( wp_get_network_secret( 'myplugin/never-set' ) );
+	}
+
+	public function test_site_and_network_scope_are_independent_under_the_same_name(): void {
+		wp_set_secret( 'myplugin/api-key', 'site-value' );
+		wp_set_network_secret( 'myplugin/api-key', 'network-value' );
+
+		$site_secret    = wp_get_secret( 'myplugin/api-key' );
+		$network_secret = wp_get_network_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $site_secret );
+		$this->assertInstanceOf( WP_Secret::class, $network_secret );
+		$this->assertSame( 'site-value', $site_secret->reveal() );
+		$this->assertSame( 'network-value', $network_secret->reveal() );
+	}
+
+	public function test_delete_removes_a_network_secret(): void {
+		wp_set_network_secret( 'myplugin/api-key', 'value' );
+
+		$this->assertTrue( wp_delete_network_secret( 'myplugin/api-key' ) );
+		$this->assertNull( wp_get_network_secret( 'myplugin/api-key' ) );
+	}
+
+	public function test_delete_does_not_touch_the_site_scope_secret_of_the_same_name(): void {
+		wp_set_secret( 'myplugin/api-key', 'site-value' );
+		wp_set_network_secret( 'myplugin/api-key', 'network-value' );
+
+		wp_delete_network_secret( 'myplugin/api-key' );
+
+		$site_secret = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $site_secret );
+		$this->assertSame( 'site-value', $site_secret->reveal() );
+	}
+
+	public function test_previous_version_and_demotion_work_the_same_as_site_scope(): void {
+		wp_set_network_secret( 'myplugin/api-key', 'first-value' );
+		wp_set_network_secret( 'myplugin/api-key', 'second-value' );
+
+		$this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::PREVIOUS, 'first-value', true );
+		$this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::CURRENT, 'second-value', true );
+	}
+
+	public function test_retire_clears_the_previous_slot(): void {
+		wp_set_network_secret( 'myplugin/api-key', 'first-value' );
+		wp_set_network_secret( 'myplugin/api-key', 'second-value' );
+
+		$this->assertTrue( wp_retire_network_secret_version( 'myplugin/api-key' ) );
+		$this->assertNull( wp_get_network_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ) );
+	}
+
+	/**
+	 * A listener is told which scope changed. Without it, a network secret and a
+	 * site secret of the same name are indistinguishable in an audit log.
+	 */
+	public function test_change_hook_reports_network_scope(): void {
+		$scopes = array();
+		add_action(
+			'wp_secret_changed',
+			function ( ...$args ) use ( &$scopes ) {
+				$scopes[] = array( $args[1], $args[6] );
+			},
+			10,
+			7
+		);
+
+		wp_set_network_secret( 'myplugin/api-key', 'first-value' );
+		wp_set_network_secret( 'myplugin/api-key', 'second-value' );
+		wp_retire_network_secret_version( 'myplugin/api-key' );
+		wp_delete_network_secret( 'myplugin/api-key' );
+		wp_set_secret( 'myplugin/api-key', 'site-value' );
+
+		$this->assertSame(
+			array(
+				array( 'created', true ),
+				array( 'updated', true ),
+				array( 'retired', true ),
+				array( 'deleted', true ),
+				array( 'created', false ),
+			),
+			$scopes
+		);
+	}
+
+	public function test_list_returns_network_secrets_only(): void {
+		wp_set_secret( 'myplugin/site-only', 'value' );
+		wp_set_network_secret( 'myplugin/network-only', 'value' );
+
+		$entries = wp_list_network_secrets();
+
+		$this->assertIsArray( $entries );
+		$this->assertSame( array( 'myplugin/network-only' ), wp_list_pluck( $entries, 'name' ) );
+	}
+
+	public function test_an_invalid_version_is_a_wp_error_same_as_site_scope(): void {
+		$this->setExpectedIncorrectUsage( '_wp_secrets_get' );
+
+		$result = wp_get_network_secret( 'myplugin/api-key', 'not-a-real-version' ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.)
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() );
+	}
+
+	public function test_set_rejects_an_invalid_name(): void {
+		$result = wp_set_network_secret( 'Not A Valid Name', 'value' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() );
+	}
+
+	/**
+	 * The entire point of network scope: a secret written from one blog's context
+	 * must read back identically from another blog's context. Requires multisite.
+	 */
+	public function test_network_secret_is_readable_from_any_blog(): void {
+		if ( ! is_multisite() ) {
+			$this->markTestSkipped( 'Requires multisite: proves network secrets are not bound to the blog that wrote them.' );
+		}
+
+		wp_set_network_secret( 'myplugin/api-key', 'cross-blog-value' );
+
+		$second_blog_id = self::factory()->blog->create();
+		switch_to_blog( $second_blog_id );
+		$secret = wp_get_network_secret( 'myplugin/api-key' );
+		restore_current_blog();
+
+		$this->assertIsSecret( $secret, 'cross-blog-value', 'Read from a second blog:' );
+	}
+
+	/**
+	 * The mirror image: a site-scope secret must NOT be readable from another blog,
+	 * proving there is no implicit fallback between scopes and no accidental sharing
+	 * across blogs for site-scope secrets.
+	 */
+	public function test_site_secret_is_not_readable_from_a_different_blog(): void {
+		if ( ! is_multisite() ) {
+			$this->markTestSkipped( 'Requires multisite: proves site secrets do not leak across blogs.' );
+		}
+
+		wp_set_secret( 'myplugin/api-key', 'blog-one-value' );
+
+		$second_blog_id = self::factory()->blog->create();
+		switch_to_blog( $second_blog_id );
+		$result = wp_get_secret( 'myplugin/api-key' );
+		restore_current_blog();
+
+		$this->assertNull( $result );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/provider.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/provider.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/provider.php	(revision 64337)
@@ -0,0 +1,271 @@
+<?php
+/**
+ * Tests for the provider seam: the outermost extension point, and what replaced
+ * the store's old supports() flag.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_Provider extends WP_UnitTestCase {
+
+	/**
+	 * A provider whose credentials are managed elsewhere – a control panel, host
+	 * tooling, a KMS with its own access policy. Reads work, writes are refused.
+	 *
+	 * @return WP_Secrets_Provider
+	 */
+	private function read_only_provider() {
+		return new class() implements WP_Secrets_Provider {
+			/**
+			 * @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
+			 */
+			public function get( string $name, string $version, bool $network = false ) {
+				return null;
+			}
+
+			/**
+			 * @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.
+			 * @param string|null $action         Overrides the action reported to `wp_secret_changed`.
+			 * @return WP_Error Always: this provider is read-only.
+			 */
+			public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ) {
+				return new WP_Error(
+					WP_SECRETS_ERROR_PROVIDER_READ_ONLY,
+					'Credentials are managed in the control panel.'
+				);
+			}
+
+			/**
+			 * @param string $name    The secret's name.
+			 * @param bool   $network Whether this is a network-scope secret.
+			 * @return WP_Error Always: this provider is read-only.
+			 */
+			public function delete( string $name, bool $network = false ) {
+				return new WP_Error( WP_SECRETS_ERROR_PROVIDER_READ_ONLY, 'Read-only.' );
+			}
+
+			/**
+			 * @param string $name    The secret's name.
+			 * @param bool   $network Whether this is a network-scope secret.
+			 * @return true Always: this provider keeps no version history.
+			 */
+			public function retire_previous( string $name, bool $network = false ) {
+				return true;
+			}
+
+			/**
+			 * @param string $name_prefix Restrict to names beginning with this prefix.
+			 * @param bool   $network     Whether to list network-scope secrets.
+			 * @return array Always empty: this provider lists nothing.
+			 *
+			 * @phpstan-return list<array{name: string, fingerprint: string, created: int, has_previous: bool, needs_rotation: bool}>
+			 */
+			public function list_secrets( string $name_prefix = '', bool $network = false ) {
+				return array();
+			}
+
+			/**
+			 * @return string
+			 */
+			public function get_label(): string {
+				return 'Example Platform (KMS)';
+			}
+
+			/**
+			 * @return string
+			 */
+			public function get_protection_boundary(): string {
+				return self::BOUNDARY_PROVIDER;
+			}
+
+			/**
+			 * @return bool
+			 */
+			public function is_writable(): bool {
+				return false;
+			}
+		};
+	}
+
+	// – the shipped provider -------------------------------------------------
+
+	public function test_the_default_provider_is_the_libsodium_one(): void {
+		$this->assertInstanceOf( 'WP_Secrets_Libsodium_Provider', _wp_secrets_get_provider() );
+	}
+
+	public function test_the_default_provider_protects_inside_wordpress(): void {
+		$this->assertSame(
+			WP_Secrets_Provider::BOUNDARY_WORDPRESS,
+			_wp_secrets_get_provider()->get_protection_boundary()
+		);
+	}
+
+	public function test_the_default_provider_accepts_writes(): void {
+		$this->assertTrue( wp_secrets_provider_is_writable() );
+	}
+
+	/**
+	 * The label is what Site Health shows an operator. It must name the key source,
+	 * because "libsodium" alone does not tell anyone whether their root key is
+	 * derived from wp-config.php salts or held in a KMS.
+	 */
+	public function test_the_default_provider_label_names_the_key_source(): void {
+		$label = wp_secrets_provider_label();
+
+		$this->assertStringContainsString( 'libsodium', $label );
+		$this->assertStringContainsString(
+			_wp_secrets_get_key_manager()->get_keyring()->get_key_source(),
+			$label
+		);
+	}
+
+	// – a platform provider, which is what the seam exists for ----------------
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_a_drop_in_provider_replaces_the_default_entirely(): void {
+		$GLOBALS['wp_secrets_provider'] = $this->read_only_provider();
+
+		$this->assertSame( 'Example Platform (KMS)', wp_secrets_provider_label() );
+		$this->assertSame(
+			WP_Secrets_Provider::BOUNDARY_PROVIDER,
+			_wp_secrets_get_provider()->get_protection_boundary()
+		);
+	}
+
+	/**
+	 * The case Pantheon and Altis both asked for: a provider that serves reads and
+	 * refuses writes, declaring it up front so a settings screen can disable its
+	 * save control rather than discovering the refusal after the fact.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_a_read_only_provider_refuses_writes_and_declares_it(): void {
+		$GLOBALS['wp_secrets_provider'] = $this->read_only_provider();
+
+		$this->assertFalse( wp_secrets_provider_is_writable() );
+
+		$result = wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_PROVIDER_READ_ONLY, $result->get_error_code() );
+	}
+
+	/**
+	 * Reads still work on a read-only provider – "read-only" is about writes, not
+	 * about being broken. Absence is still absence.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_a_read_only_provider_still_serves_reads(): void {
+		$GLOBALS['wp_secrets_provider'] = $this->read_only_provider();
+
+		$this->assertNull( wp_get_secret( 'myplugin/api-key' ) );
+	}
+
+	// – fail closed -----------------------------------------------------------
+
+	/**
+	 * A broken drop-in must not quietly revert to the default provider: that would
+	 * downgrade a site's protection at the exact moment nobody is watching.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_a_broken_dropin_fails_closed_rather_than_reverting_to_the_default(): void {
+		$GLOBALS['wp_secrets_dropin_broken'] = true;
+
+		$provider = _wp_secrets_get_provider();
+
+		$this->assertNotInstanceOf( 'WP_Secrets_Libsodium_Provider', $provider );
+		$this->assertInstanceOf( 'WP_Secrets_Broken_Provider', $provider );
+		$this->assertFalse( $provider->is_writable() );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_a_broken_dropin_makes_every_operation_a_wp_error(): void {
+		$GLOBALS['wp_secrets_dropin_broken'] = true;
+
+		$this->assertWPError( wp_set_secret( 'myplugin/api-key', 'value' ) );
+		$this->assertWPError( wp_get_secret( 'myplugin/api-key' ) );
+		$this->assertWPError( wp_delete_secret( 'myplugin/api-key' ) );
+		$this->assertWPError( wp_list_secrets() );
+		$this->assertWPError( wp_retire_secret_version( 'myplugin/api-key' ) );
+	}
+
+	/**
+	 * Specifically not null: a misconfigured credential backend must never look
+	 * like a working site that happens to have no secrets in it yet.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_a_broken_dropin_read_is_an_error_not_an_absence(): void {
+		$GLOBALS['wp_secrets_dropin_broken'] = true;
+
+		$this->assertNotNull( wp_get_secret( 'myplugin/api-key' ) );
+	}
+
+	/**
+	 * A provider global set to something that is not a provider fails closed, the
+	 * same posture the store and keyring globals take – for those two,
+	 * wp_load_secrets_dropin() marks the drop-in broken on a type mismatch and
+	 * both getters return their Broken_* sentinel.
+	 *
+	 * Falling through to the default provider instead would be the worst available
+	 * outcome: a platform drop-in that misnames its class would have every read
+	 * served from wp_options, find nothing there, and report a site's credentials
+	 * absent when they are merely unreachable.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_a_non_provider_global_fails_closed(): void {
+		$GLOBALS['wp_secrets_provider'] = new stdClass();
+
+		$this->assertInstanceOf( 'WP_Secrets_Broken_Provider', _wp_secrets_get_provider() );
+	}
+
+	/**
+	 * And it is an error rather than an absence, which is the property that
+	 * actually protects the operator.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_a_non_provider_global_reads_as_an_error_not_an_absence(): void {
+		$GLOBALS['wp_secrets_provider'] = new stdClass();
+
+		$result = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertNotNull( $result );
+		$this->assertWPError( $result );
+	}
+
+	/**
+	 * The counterpart, and the case the routing docs must not misdescribe: a drop-in
+	 * that overrides only the keyring registers no provider at all, and that is a
+	 * supported arrangement rather than a broken one. It gets the shipped libsodium
+	 * provider, composed with the keyring it did supply.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_a_keyring_only_dropin_still_gets_the_default_provider(): void {
+		$GLOBALS['wp_secrets_keyring'] = new Mock_Keyring();
+
+		$this->assertInstanceOf( 'WP_Secrets_Libsodium_Provider', _wp_secrets_get_provider() );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/providerConformance.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/providerConformance.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/providerConformance.php	(revision 64337)
@@ -0,0 +1,19 @@
+<?php
+/**
+ * Runs the provider conformance suite against the provider WordPress ships.
+ *
+ * The shipped provider passing is what makes the suite trustworthy: a conformance
+ * kit nobody has run against a working implementation is a wish list. It also
+ * means a host can compare their own failures against a known-good subject.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_LibsodiumProviderConformance extends WP_Secrets_Provider_Conformance {
+
+	protected function provider() {
+		return new WP_Secrets_Libsodium_Provider(
+			new WP_Secrets_Option_Store(),
+			new WP_Secrets_Key_Manager()
+		);
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/readonlyProviderConformance.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/readonlyProviderConformance.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/readonlyProviderConformance.php	(revision 64337)
@@ -0,0 +1,97 @@
+<?php
+/**
+ * And against a read-only provider, which is the shape Pantheon and Altis
+ * described: reads served, writes refused by the platform's own tooling.
+ *
+ * Included so the suite is exercised on both sides of every is_writable() branch.
+ * A conformance kit that has only ever run against a writable provider would not
+ * have proven that its own skips work.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_ReadOnlyProviderConformance extends WP_Secrets_Provider_Conformance {
+
+	/**
+	 * The provider under test: one that serves reads and refuses every write.
+	 *
+	 * @return WP_Secrets_Provider
+	 */
+	protected function provider() {
+		return new class() implements WP_Secrets_Provider {
+			/**
+			 * @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
+			 */
+			public function get( string $name, string $version, bool $network = false ) {
+				return null;
+			}
+
+			/**
+			 * @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.
+			 * @param string|null $action         Overrides the action reported to `wp_secret_changed`.
+			 * @return WP_Error Always: this provider is read-only.
+			 */
+			public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ) {
+				return new WP_Error(
+					WP_SECRETS_ERROR_PROVIDER_READ_ONLY,
+					'Credentials are managed in the platform control panel.'
+				);
+			}
+
+			/**
+			 * @param string $name    The secret's name.
+			 * @param bool   $network Whether this is a network-scope secret.
+			 * @return WP_Error Always: this provider is read-only.
+			 */
+			public function delete( string $name, bool $network = false ) {
+				return new WP_Error( WP_SECRETS_ERROR_PROVIDER_READ_ONLY, 'Read-only.' );
+			}
+
+			/**
+			 * @param string $name    The secret's name.
+			 * @param bool   $network Whether this is a network-scope secret.
+			 * @return WP_Error Always: this provider is read-only.
+			 */
+			public function retire_previous( string $name, bool $network = false ) {
+				return new WP_Error( WP_SECRETS_ERROR_PROVIDER_READ_ONLY, 'Read-only.' );
+			}
+
+			/**
+			 * @param string $name_prefix Restrict to names beginning with this prefix.
+			 * @param bool   $network     Whether to list network-scope secrets.
+			 * @return array Always empty: this provider lists nothing.
+			 *
+			 * @phpstan-return list<array{name: string, fingerprint: string, created: int, has_previous: bool, needs_rotation: bool}>
+			 */
+			public function list_secrets( string $name_prefix = '', bool $network = false ) {
+				return array();
+			}
+
+			/**
+			 * @return string
+			 */
+			public function get_label(): string {
+				return 'Example Platform (control panel)';
+			}
+
+			/**
+			 * @return string
+			 */
+			public function get_protection_boundary(): string {
+				return self::BOUNDARY_PROVIDER;
+			}
+
+			/**
+			 * @return bool
+			 */
+			public function is_writable(): bool {
+				return false;
+			}
+		};
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/siteHealth.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/siteHealth.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/siteHealth.php	(revision 64337)
@@ -0,0 +1,240 @@
+<?php
+/**
+ * Tests for the Site Health integration in src/wp-admin/includes/secrets-site-health.php.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_SiteHealth extends WP_UnitTestCase {
+
+	/**
+	 * Returns the Secrets API section's fields from Site Health debug information,
+	 * having checked that the section and each of its fields has the expected shape.
+	 *
+	 * @param array<string, array<string, mixed>> $info Debug information sections.
+	 * @return array<string, array<mixed>> The section's fields, keyed by field name.
+	 */
+	private function debug_fields( array $info ): array {
+		$this->assertArrayHasKey( 'secrets-api', $info );
+		$this->assertArrayHasKey( 'fields', $info['secrets-api'] );
+		$this->assertIsArray( $info['secrets-api']['fields'] );
+
+		$fields = array();
+
+		foreach ( $info['secrets-api']['fields'] as $key => $field ) {
+			$this->assertIsString( $key );
+			$this->assertIsArray( $field );
+
+			$fields[ $key ] = $field;
+		}
+
+		return $fields;
+	}
+
+	public function test_registers_three_tests_under_direct(): void {
+		$tests = wp_secrets_site_health_tests( array() );
+
+		$this->assertArrayHasKey( 'direct', $tests );
+		$this->assertArrayHasKey( 'secrets_api_key_source', $tests['direct'] );
+		$this->assertArrayHasKey( 'secrets_api_undecryptable', $tests['direct'] );
+		$this->assertArrayHasKey( 'secrets_api_needs_rotation', $tests['direct'] );
+
+		foreach ( $tests['direct'] as $key => $test ) {
+			$this->assertIsString( $test['test'] );
+			$this->assertTrue( function_exists( $test['test'] ), "Registered test callback \"{$test['test']}\" for \"{$key}\" does not exist." );
+		}
+	}
+
+	public function test_preserves_existing_tests(): void {
+		$tests = wp_secrets_site_health_tests( array( 'direct' => array( 'existing_test' => array( 'label' => 'x' ) ) ) );
+
+		$this->assertArrayHasKey( 'existing_test', $tests['direct'] );
+	}
+
+	public function test_key_source_test_recommends_a_dedicated_key_by_default(): void {
+		if ( 'put your unique phrase here' === LOGGED_IN_KEY || 'put your unique phrase here' === LOGGED_IN_SALT ) {
+			$this->markTestSkipped( 'Needs real LOGGED_IN_KEY and LOGGED_IN_SALT values; wp-tests-config-sample.php ships placeholders.' );
+		}
+
+		$result = wp_secrets_site_health_test_key_source();
+
+		$this->assertSame( 'recommended', $result['status'] );
+		$this->assertStringContainsString( 'WP_SECRETS_KEY', $result['description'] );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_key_source_test_recommends_regeneration_for_a_legacy_key(): void {
+		define( 'WP_SECRETS_KEY', 'not-base64-32!!' );
+
+		$result = wp_secrets_site_health_test_key_source();
+
+		$this->assertSame( 'recommended', $result['status'] );
+		$this->assertStringContainsString( 'generate-key', $result['description'] );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_key_source_test_is_good_for_a_dedicated_base64_key(): void {
+		define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'A', 32 ) ) );
+
+		$result = wp_secrets_site_health_test_key_source();
+
+		$this->assertSame( 'good', $result['status'] );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_key_source_test_is_critical_when_the_keyring_is_broken(): void {
+		$GLOBALS['wp_secrets_dropin_broken'] = true;
+
+		$result = wp_secrets_site_health_test_key_source();
+
+		$this->assertSame( 'critical', $result['status'] );
+	}
+
+	public function test_undecryptable_test_is_good_when_nothing_is_broken(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$result = wp_secrets_site_health_test_undecryptable();
+
+		$this->assertSame( 'good', $result['status'] );
+	}
+
+	public function test_undecryptable_test_is_good_with_no_secrets_at_all(): void {
+		$result = wp_secrets_site_health_test_undecryptable();
+
+		$this->assertSame( 'good', $result['status'] );
+	}
+
+	public function test_undecryptable_test_is_critical_and_lists_the_broken_secret(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+		$secret = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+		$fingerprint = $secret->fingerprint();
+
+		$record = get_option( '_wp_secret_myplugin/api-key' );
+
+		$this->assertIsArray( $record );
+		$this->assertArrayHasKey( 'current', $record );
+		$this->assertIsArray( $record['current'] );
+
+		$record['current']['ct'] = base64_encode( 'not decryptable' );
+		update_option( '_wp_secret_myplugin/api-key', $record, false );
+
+		$result = wp_secrets_site_health_test_undecryptable();
+
+		$this->assertSame( 'critical', $result['status'] );
+		$this->assertStringContainsString( 'myplugin/api-key', $result['description'] );
+		$this->assertStringContainsString( $fingerprint, $result['description'] );
+	}
+
+	public function test_undecryptable_test_never_contains_a_plaintext(): void {
+		wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' );
+
+		$record = get_option( '_wp_secret_myplugin/api-key' );
+
+		$this->assertIsArray( $record );
+		$this->assertArrayHasKey( 'current', $record );
+		$this->assertIsArray( $record['current'] );
+
+		$record['current']['ct'] = base64_encode( 'not decryptable' );
+		update_option( '_wp_secret_myplugin/api-key', $record, false );
+
+		$result = wp_secrets_site_health_test_undecryptable();
+
+		$this->assertStringNotContainsString( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $result['description'] );
+	}
+
+	public function test_needs_rotation_test_is_good_by_default(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$result = wp_secrets_site_health_test_needs_rotation();
+
+		$this->assertSame( 'good', $result['status'] );
+	}
+
+	public function test_needs_rotation_test_is_recommended_after_an_import(): void {
+		update_option( 'my_plugin_api_key', 'value' );
+		wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' );
+
+		$result = wp_secrets_site_health_test_needs_rotation();
+
+		$this->assertSame( 'recommended', $result['status'] );
+		$this->assertStringContainsString( '1', $result['description'] );
+	}
+
+	public function test_debug_info_reports_expected_fields(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$info = wp_secrets_site_health_debug_info( array() );
+
+		$this->assertArrayHasKey( 'secrets-api', $info );
+		$fields = $this->debug_fields( $info );
+
+		$this->assertArrayHasKey( 'dropin_active', $fields );
+		$this->assertArrayHasKey( 'provider_class', $fields );
+		$this->assertArrayHasKey( 'keyring_class', $fields );
+		$this->assertArrayHasKey( 'key_source', $fields );
+		$this->assertArrayHasKey( 'record_version', $fields );
+		$this->assertArrayHasKey( 'site_secret_count', $fields );
+
+		$this->assertSame( 'WP_Secrets_Libsodium_Provider', $fields['provider_class']['value'] );
+		$this->assertSame( 'WP_Secrets_Config_Key_Provider', $fields['keyring_class']['value'] );
+		$this->assertSame( '1', $fields['record_version']['value'] );
+		$this->assertSame( '1', $fields['site_secret_count']['value'] );
+	}
+
+	public function test_debug_info_reports_no_dropin_by_default(): void {
+		$info = wp_secrets_site_health_debug_info( array() );
+
+		$fields = $this->debug_fields( $info );
+
+		$this->assertArrayHasKey( 'dropin_active', $fields );
+		$this->assertSame( 'No', $fields['dropin_active']['value'] );
+	}
+
+	public function test_debug_info_never_contains_a_plaintext(): void {
+		wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' );
+
+		$info = wp_secrets_site_health_debug_info( array() );
+
+		$dump = wp_json_encode( $info['secrets-api'] );
+
+		$this->assertIsString( $dump );
+		$this->assertStringNotContainsString( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $dump );
+	}
+
+	public function test_debug_info_omits_network_fields_on_single_site(): void {
+		$info = wp_secrets_site_health_debug_info( array() );
+
+		$this->assertArrayNotHasKey( 'network_secret_count', $this->debug_fields( $info ) );
+	}
+
+	/**
+	 * Network secret counts appear only for a super admin on a multisite install –
+	 * never for a site administrator who is not one.
+	 */
+	public function test_debug_info_includes_network_fields_only_for_a_super_admin(): void {
+		if ( ! is_multisite() ) {
+			$this->markTestSkipped( 'Requires multisite.' );
+		}
+
+		$user_id = self::factory()->user->create();
+		wp_set_current_user( $user_id );
+
+		$info = wp_secrets_site_health_debug_info( array() );
+		$this->assertArrayNotHasKey( 'network_secret_count', $this->debug_fields( $info ), 'A non-super-admin must not see network secret counts.' );
+
+		grant_super_admin( $user_id );
+
+		$info = wp_secrets_site_health_debug_info( array() );
+		$this->assertArrayHasKey( 'network_secret_count', $this->debug_fields( $info ) );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/threeStateContract.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/threeStateContract.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/threeStateContract.php	(revision 64337)
@@ -0,0 +1,203 @@
+<?php
+/**
+ * Dedicated coverage for wp_get_secret()'s three-state return contract: a WP_Secret
+ * if it exists and decrypts, null if it does not exist, WP_Error if it exists but
+ * could not be retrieved. The whole design rests on never collapsing these, so every
+ * broken case here explicitly asserts `null !== $result` as well as WP_Error.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_ThreeStateContract extends WP_UnitTestCase {
+
+	public function test_absent_is_null(): void {
+		$result = wp_get_secret( 'myplugin/never-set' );
+
+		$this->assertNull( $result );
+	}
+
+	public function test_previous_slot_absent_on_an_existing_secret_is_also_null(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$result = wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS );
+
+		$this->assertNull( $result );
+	}
+
+	public function test_malformed_record_is_wp_error_not_null(): void {
+		update_option( '_wp_secret_myplugin/api-key', 'this is a string, not a record array', false );
+
+		$result = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertNotNull( $result );
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() );
+	}
+
+	public function test_record_missing_the_current_slot_is_wp_error_not_null(): void {
+		update_option( '_wp_secret_myplugin/api-key', array( 'v' => 1 ), false );
+
+		$result = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertNotNull( $result );
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() );
+	}
+
+	public function test_unsupported_record_version_is_wp_error_not_null(): void {
+		update_option(
+			'_wp_secret_myplugin/api-key',
+			array(
+				'v'       => 2,
+				'current' => array(
+					'dk'          => 'x',
+					'dk_nonce'    => 'x',
+					'ct'          => 'x',
+					'nonce'       => 'x',
+					'fingerprint' => 'x',
+				),
+			),
+			false
+		);
+
+		$result = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertNotNull( $result );
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_RECORD_UNSUPPORTED_VERSION, $result->get_error_code() );
+	}
+
+	/**
+	 * A record's AAD binds it to the specific name it was encrypted under. Storing a
+	 * legitimately-encrypted record under a different name's option row – as if it
+	 * had been copied or corrupted – must fail decryption, not silently succeed
+	 * under the wrong context or crash.
+	 */
+	public function test_aad_mismatch_from_a_copied_record_is_wp_error_not_null(): void {
+		wp_set_secret( 'myplugin/original-name', 'value' );
+		$copied_record = get_option( '_wp_secret_myplugin/original-name' );
+
+		update_option( '_wp_secret_myplugin/different-name', $copied_record, false );
+
+		$result = wp_get_secret( 'myplugin/different-name' );
+
+		$this->assertNotNull( $result );
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_DECRYPTION_FAILED, $result->get_error_code() );
+	}
+
+	/**
+	 * An operator sets the constant wrong after secrets exist: WP_SECRETS_KEY is
+	 * defined with a value that cannot serve as a usable key once secrets already
+	 * exist under the site's real key. wp_get_secret() must fail with WP_Error, not
+	 * silently return null as if the secret never existed.
+	 *
+	 * The write goes through a hand-built WP_Secrets_Libsodium_Provider rather than
+	 * wp_set_secret() so it bypasses the static _wp_secrets_get_key_manager(), whose
+	 * request-scoped root-key cache (ADR 0009) would otherwise keep serving the
+	 * already-unwrapped key and mask the misconfiguration this test defines below.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_key_unavailable_is_wp_error_not_null(): void {
+		if ( 'put your unique phrase here' === LOGGED_IN_KEY || 'put your unique phrase here' === LOGGED_IN_SALT ) {
+			$this->markTestSkipped( 'Needs real LOGGED_IN_KEY and LOGGED_IN_SALT values; wp-tests-config-sample.php ships placeholders.' );
+		}
+
+		$provider = new WP_Secrets_Libsodium_Provider(
+			new WP_Secrets_Option_Store(),
+			new WP_Secrets_Key_Manager( new WP_Secrets_Config_Key_Provider() )
+		);
+		$provider->set( 'myplugin/api-key', 'value' );
+
+		define( 'WP_SECRETS_KEY', 424242 );
+
+		$result = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertNotNull( $result );
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() );
+	}
+
+	/**
+	 * Written under the ambient salt-fallback key, then read back after the stored
+	 * wrapped root key has been corrupted – simulating the option row being
+	 * damaged after secrets already exist. get_master_key() must fail before
+	 * decryption is ever attempted, since a usable root key was never obtained.
+	 */
+	public function test_a_corrupted_wrapped_root_key_is_wp_error_not_null(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, 'not-a-valid-wrapped-value' );
+
+		$result = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertNotNull( $result );
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() );
+	}
+
+	/**
+	 * A record whose crypto fields are intact but whose unauthenticated
+	 * 'fingerprint' field has been removed still decrypts, and must return a
+	 * WP_Secret rather than throwing. Regression test: this previously handed an
+	 * empty fingerprint to WP_Secret's constructor, which rejects one, producing an
+	 * uncaught InvalidArgumentException – a fatal, and a fourth state the contract
+	 * does not allow.
+	 */
+	public function test_record_missing_its_fingerprint_field_still_returns_a_secret(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$record = get_option( '_wp_secret_myplugin/api-key' );
+
+		$this->assertIsArray( $record );
+		$this->assertArrayHasKey( 'current', $record );
+		$this->assertIsArray( $record['current'] );
+
+		unset( $record['current']['fingerprint'] );
+		update_option( '_wp_secret_myplugin/api-key', $record, false );
+
+		$result = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $result );
+		$this->assertSame( 'value', $result->reveal() );
+		$this->assertNotSame( '', $result->fingerprint() );
+	}
+
+	/**
+	 * The 'fingerprint' field is outside the AAD, so anyone who can write to the
+	 * store can change it freely. WP_Secret::fingerprint() must report the value
+	 * recomputed from the decrypted plaintext, not whatever the record claims.
+	 */
+	public function test_fingerprint_is_recomputed_not_read_from_the_record(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+		$before = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $before );
+		$genuine = $before->fingerprint();
+
+		$record = get_option( '_wp_secret_myplugin/api-key' );
+
+		$this->assertIsArray( $record );
+		$this->assertArrayHasKey( 'current', $record );
+		$this->assertIsArray( $record['current'] );
+
+		$record['current']['fingerprint'] = 'deadbeefdeadbeefdeadbeefdeadbeef';
+		update_option( '_wp_secret_myplugin/api-key', $record, false );
+
+		$after = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $after );
+		$this->assertSame( $genuine, $after->fingerprint() );
+	}
+
+	public function test_exists_and_decrypts_is_a_wp_secret(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$result = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertNotNull( $result );
+		$this->assertNotWPError( $result );
+		$this->assertInstanceOf( WP_Secret::class, $result );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/versionSlots.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/versionSlots.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/versionSlots.php	(revision 64337)
@@ -0,0 +1,227 @@
+<?php
+/**
+ * Version slot behaviour: demotion on overwrite, PREVIOUS retrieval, and
+ * wp_retire_secret_version(). PREVIOUS retrieval is
+ * "the single most important new test in the suite" – the prior proof-of-concept
+ * could not do this at all.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_VersionSlots extends WP_UnitTestCase {
+
+	use WP_Secrets_Assertions;
+
+	/**
+	 * Reads one slot of the stored record for 'myplugin/api-key' straight from
+	 * the options table, bypassing the API.
+	 *
+	 * @param string $slot A WP_Secret_Version constant.
+	 * @return array<mixed> The slot as stored.
+	 */
+	private function stored_slot( string $slot ): array {
+		$record = get_option( '_wp_secret_myplugin/api-key' );
+
+		$this->assertIsArray( $record );
+		$this->assertArrayHasKey( $slot, $record );
+		$this->assertIsArray( $record[ $slot ] );
+
+		return $record[ $slot ];
+	}
+
+	public function test_first_write_leaves_no_previous_slot(): void {
+		wp_set_secret( 'myplugin/api-key', 'first-value' );
+
+		$this->assertNull( wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ) );
+	}
+
+	/**
+	 * The headline case: PREVIOUS must actually decrypt back to the prior value.
+	 */
+	public function test_previous_returns_the_actual_prior_value(): void {
+		wp_set_secret( 'myplugin/api-key', 'first-value' );
+		wp_set_secret( 'myplugin/api-key', 'second-value' );
+
+		$this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::PREVIOUS, 'first-value' );
+		$this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::CURRENT, 'second-value' );
+	}
+
+	public function test_third_write_discards_the_oldest_value(): void {
+		wp_set_secret( 'myplugin/api-key', 'value-a' );
+		wp_set_secret( 'myplugin/api-key', 'value-b' );
+		wp_set_secret( 'myplugin/api-key', 'value-c' );
+
+		$this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::PREVIOUS, 'value-b' );
+
+		// value-a is gone from the stored record entirely, not merely unreachable
+		// through the two named slots.
+		$this->assertNeverContainsPlaintext( 'value-a', get_option( '_wp_secret_myplugin/api-key' ) );
+	}
+
+	public function test_previous_preserves_its_original_fingerprint(): void {
+		wp_set_secret( 'myplugin/api-key', 'first-value' );
+		$original = wp_get_secret( 'myplugin/api-key' );
+		$this->assertInstanceOf( WP_Secret::class, $original );
+		$original_fingerprint = $original->fingerprint();
+
+		wp_set_secret( 'myplugin/api-key', 'second-value' );
+
+		$previous = wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS );
+		$this->assertInstanceOf( WP_Secret::class, $previous );
+		$this->assertSame( $original_fingerprint, $previous->fingerprint() );
+	}
+
+	public function test_previous_preserves_its_original_created_timestamp(): void {
+		wp_set_secret( 'myplugin/api-key', 'first-value' );
+		$original_created = $this->stored_slot( WP_Secret_Version::CURRENT )['created'];
+
+		wp_set_secret( 'myplugin/api-key', 'second-value' );
+
+		$demoted_created = $this->stored_slot( WP_Secret_Version::PREVIOUS )['created'];
+
+		$this->assertSame( $original_created, $demoted_created );
+	}
+
+	public function test_previous_preserves_its_needs_rotation_flag(): void {
+		wp_set_secret( 'myplugin/api-key', 'first-value' );
+
+		// No public API sets this flag yet (that lands with wp_import_option_as_secret()
+		// in a later commit); set it directly to prove demotion carries it forward.
+		$record = get_option( '_wp_secret_myplugin/api-key' );
+		$this->assertIsArray( $record );
+		$this->assertArrayHasKey( 'current', $record );
+		$this->assertIsArray( $record['current'] );
+		$record['current']['needs_rotation'] = true;
+		update_option( '_wp_secret_myplugin/api-key', $record, false );
+
+		wp_set_secret( 'myplugin/api-key', 'second-value' );
+
+		$this->assertTrue( $this->stored_slot( WP_Secret_Version::PREVIOUS )['needs_rotation'] );
+	}
+
+	/**
+	 * Regression test for a demotion bug found while building this: a slot's AAD
+	 * binds its ciphertext to the slot it occupies, so naively copying the current
+	 * slot's stored bytes into 'previous' would leave it permanently undecryptable
+	 * there. Demoting must re-encrypt, which means the stored ciphertext actually
+	 * changes even though the plaintext does not.
+	 */
+	public function test_demotion_actually_reencrypts_rather_than_copying_ciphertext(): void {
+		wp_set_secret( 'myplugin/api-key', 'first-value' );
+		$original_ciphertext = $this->stored_slot( WP_Secret_Version::CURRENT )['ct'];
+
+		wp_set_secret( 'myplugin/api-key', 'second-value' );
+
+		$demoted_ciphertext = $this->stored_slot( WP_Secret_Version::PREVIOUS )['ct'];
+
+		$this->assertNotSame( $original_ciphertext, $demoted_ciphertext );
+		// And it must still actually decrypt to the original plaintext.
+		$previous = wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS );
+		$this->assertInstanceOf( WP_Secret::class, $previous );
+		$this->assertSame( 'first-value', $previous->reveal() );
+	}
+
+	public function test_previous_on_a_secret_with_no_previous_slot_is_null(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$this->assertNull( wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ) );
+	}
+
+	/**
+	 * If the outgoing current slot is already undecryptable (corrupted, or
+	 * orphaned by a botched rotation), the write must still succeed rather than
+	 * inheriting the old corruption – refusing here would be the exact
+	 * corrupted-record-blocks-everything failure this API is built to avoid.
+	 */
+	public function test_a_write_succeeds_even_if_the_outgoing_current_slot_cannot_be_decrypted(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$record = get_option( '_wp_secret_myplugin/api-key' );
+		$this->assertIsArray( $record );
+		$this->assertArrayHasKey( 'current', $record );
+		$this->assertIsArray( $record['current'] );
+		$record['current']['ct'] = base64_encode( 'not decryptable under any key' );
+		update_option( '_wp_secret_myplugin/api-key', $record, false );
+
+		$this->assertTrue( wp_set_secret( 'myplugin/api-key', 'new-value' ) );
+		$current = wp_get_secret( 'myplugin/api-key' );
+		$this->assertInstanceOf( WP_Secret::class, $current );
+		$this->assertSame( 'new-value', $current->reveal() );
+		// The undecryptable slot could not be demoted, so it is simply gone.
+		$this->assertNull( wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ) );
+	}
+
+	public function test_retire_clears_previous_and_leaves_current_intact(): void {
+		wp_set_secret( 'myplugin/api-key', 'first-value' );
+		wp_set_secret( 'myplugin/api-key', 'second-value' );
+
+		$this->assertTrue( wp_retire_secret_version( 'myplugin/api-key' ) );
+
+		$this->assertNull( wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ) );
+		$current = wp_get_secret( 'myplugin/api-key' );
+		$this->assertInstanceOf( WP_Secret::class, $current );
+		$this->assertSame( 'second-value', $current->reveal() );
+	}
+
+	public function test_retire_on_a_secret_with_no_previous_slot_is_a_successful_noop(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$this->assertTrue( wp_retire_secret_version( 'myplugin/api-key' ) );
+	}
+
+	public function test_retire_on_a_never_set_secret_is_a_successful_noop(): void {
+		$this->assertTrue( wp_retire_secret_version( 'myplugin/never-set' ) );
+	}
+
+	public function test_retire_rejects_an_invalid_name(): void {
+		$result = wp_retire_secret_version( 'Not A Valid Name' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() );
+	}
+
+	public function test_retire_fires_the_change_hook_with_the_retired_action(): void {
+		wp_set_secret( 'myplugin/api-key', 'first-value' );
+		$original = wp_get_secret( 'myplugin/api-key' );
+		$this->assertInstanceOf( WP_Secret::class, $original );
+		$fingerprint_being_retired = $original->fingerprint();
+		wp_set_secret( 'myplugin/api-key', 'second-value' );
+
+		$captured = null;
+		add_action(
+			'wp_secret_changed',
+			function ( ...$args ) use ( &$captured ) {
+				$captured = $args;
+			},
+			10,
+			7
+		);
+
+		wp_retire_secret_version( 'myplugin/api-key' );
+
+		$this->assertIsArray( $captured );
+		$this->assertCount( 7, $captured );
+
+		list( $name, $action, , , $old_fingerprint, $new_fingerprint ) = $captured;
+
+		$this->assertSame( 'myplugin/api-key', $name );
+		$this->assertSame( 'retired', $action );
+		$this->assertSame( $fingerprint_being_retired, $old_fingerprint );
+		$this->assertSame( '', $new_fingerprint );
+	}
+
+	public function test_retire_does_not_fire_the_change_hook_on_a_noop(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$fired = false;
+		add_action(
+			'wp_secret_changed',
+			function () use ( &$fired ) {
+				$fired = true;
+			}
+		);
+
+		wp_retire_secret_version( 'myplugin/api-key' );
+
+		$this->assertFalse( $fired );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/wpDeleteSecret.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/wpDeleteSecret.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/wpDeleteSecret.php	(revision 64337)
@@ -0,0 +1,89 @@
+<?php
+/**
+ * Tests for wp_delete_secret().
+ *
+ * @group secrets
+ */
+class Tests_Secrets_WpDeleteSecret extends WP_UnitTestCase {
+
+	public function test_deletes_an_existing_secret(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$this->assertTrue( wp_delete_secret( 'myplugin/api-key' ) );
+		$this->assertNull( wp_get_secret( 'myplugin/api-key' ) );
+	}
+
+	public function test_deleting_an_absent_secret_is_not_an_error(): void {
+		$this->assertTrue( wp_delete_secret( 'myplugin/never-set' ) );
+	}
+
+	public function test_rejects_an_invalid_name(): void {
+		$result = wp_delete_secret( 'Not A Valid Name' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() );
+	}
+
+	public function test_change_hook_fires_with_deleted_action(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+		$secret = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+
+		$fingerprint = $secret->fingerprint();
+
+		$captured = null;
+		add_action(
+			'wp_secret_changed',
+			function ( ...$args ) use ( &$captured ) {
+				$captured = $args;
+			},
+			10,
+			7
+		);
+
+		wp_delete_secret( 'myplugin/api-key' );
+
+		$this->assertIsArray( $captured );
+
+		list( $name, $action, , , $old_fingerprint, $new_fingerprint ) = $captured;
+
+		$this->assertSame( 'myplugin/api-key', $name );
+		$this->assertSame( 'deleted', $action );
+		$this->assertSame( $fingerprint, $old_fingerprint );
+		$this->assertSame( '', $new_fingerprint );
+	}
+
+	public function test_change_hook_does_not_fire_when_nothing_was_deleted(): void {
+		$fired = false;
+		add_action(
+			'wp_secret_changed',
+			function () use ( &$fired ) {
+				$fired = true;
+			}
+		);
+
+		wp_delete_secret( 'myplugin/never-set' );
+
+		$this->assertFalse( $fired );
+	}
+
+	/**
+	 * A corrupted record must still be removable. Refusing here would leave a secret
+	 * permanently stuck – unreadable, unrepairable, and undeletable without editing
+	 * the database by hand.
+	 */
+	public function test_a_corrupt_record_is_still_deletable(): void {
+		update_option( '_wp_secret_myplugin/corrupt', 'not a record at all', false );
+
+		$this->assertTrue( wp_delete_secret( 'myplugin/corrupt' ) );
+		$this->assertNull( wp_get_secret( 'myplugin/corrupt' ) );
+	}
+
+	public function test_no_capability_check_is_applied(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+		wp_set_current_user( 0 );
+
+		$this->assertTrue( wp_delete_secret( 'myplugin/api-key' ) );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php	(revision 64337)
@@ -0,0 +1,117 @@
+<?php
+/**
+ * Tests for wp_import_option_as_secret().
+ *
+ * @group secrets
+ */
+class Tests_Secrets_WpImportOptionAsSecret extends WP_UnitTestCase {
+
+	public function test_imports_an_existing_option_value(): void {
+		update_option( 'my_plugin_api_key', 'sk_live_from_an_option' );
+
+		$this->assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ) );
+
+		$secret = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+		$this->assertSame( 'sk_live_from_an_option', $secret->reveal() );
+	}
+
+	public function test_leaves_the_source_option_untouched(): void {
+		update_option( 'my_plugin_api_key', 'sk_live_from_an_option' );
+
+		wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' );
+
+		$this->assertSame( 'sk_live_from_an_option', get_option( 'my_plugin_api_key' ) );
+	}
+
+	public function test_flags_the_imported_secret_for_rotation(): void {
+		update_option( 'my_plugin_api_key', 'value' );
+		wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' );
+
+		$record = get_option( '_wp_secret_myplugin/api-key' );
+
+		$this->assertIsArray( $record );
+		$this->assertArrayHasKey( 'current', $record );
+		$this->assertIsArray( $record['current'] );
+		$this->assertTrue( $record['current']['needs_rotation'] );
+	}
+
+	public function test_an_ordinary_write_does_not_flag_needs_rotation(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$record = get_option( '_wp_secret_myplugin/api-key' );
+
+		$this->assertIsArray( $record );
+		$this->assertArrayHasKey( 'current', $record );
+		$this->assertIsArray( $record['current'] );
+		$this->assertFalse( $record['current']['needs_rotation'] );
+	}
+
+	public function test_rejects_a_nonexistent_option(): void {
+		$result = wp_import_option_as_secret( 'this_option_was_never_set', 'myplugin/api-key' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() );
+	}
+
+	public function test_rejects_a_non_string_option_value(): void {
+		update_option( 'my_plugin_settings', array( 'not' => 'a string' ) );
+
+		$result = wp_import_option_as_secret( 'my_plugin_settings', 'myplugin/api-key' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() );
+	}
+
+	public function test_rejects_an_invalid_destination_name(): void {
+		update_option( 'my_plugin_api_key', 'value' );
+
+		$result = wp_import_option_as_secret( 'my_plugin_api_key', 'Not A Valid Name' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() );
+	}
+
+	public function test_fires_the_change_hook_with_the_imported_action(): void {
+		update_option( 'my_plugin_api_key', 'value' );
+
+		$captured = null;
+		add_action(
+			'wp_secret_changed',
+			function ( ...$args ) use ( &$captured ) {
+				$captured = $args;
+			},
+			10,
+			7
+		);
+
+		wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' );
+
+		$this->assertIsArray( $captured );
+		list( $name, $action ) = $captured;
+
+		$this->assertSame( 'myplugin/api-key', $name );
+		$this->assertSame( 'imported', $action );
+	}
+
+	/**
+	 * An import onto an existing secret behaves like any other write, including
+	 * demotion – there is nothing special about the destination just because the
+	 * new value's source happens to be an option.
+	 */
+	public function test_importing_over_an_existing_secret_demotes_it_like_any_write(): void {
+		wp_set_secret( 'myplugin/api-key', 'original-value' );
+		update_option( 'my_plugin_api_key', 'imported-value' );
+
+		wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' );
+
+		$current  = wp_get_secret( 'myplugin/api-key' );
+		$previous = wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS );
+
+		$this->assertInstanceOf( WP_Secret::class, $current );
+		$this->assertInstanceOf( WP_Secret::class, $previous );
+		$this->assertSame( 'imported-value', $current->reveal() );
+		$this->assertSame( 'original-value', $previous->reveal() );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/wpListSecrets.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/wpListSecrets.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/wpListSecrets.php	(revision 64337)
@@ -0,0 +1,137 @@
+<?php
+/**
+ * Tests for wp_list_secrets().
+ *
+ * @group secrets
+ */
+class Tests_Secrets_WpListSecrets extends WP_UnitTestCase {
+
+	public function test_returns_an_empty_array_when_nothing_is_set(): void {
+		$this->assertSame( array(), wp_list_secrets() );
+	}
+
+	public function test_lists_every_secret_with_the_expected_keys(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$entries = wp_list_secrets();
+
+		$this->assertIsArray( $entries );
+		$this->assertCount( 1, $entries );
+		$this->assertSame(
+			array( 'name', 'fingerprint', 'created', 'has_previous', 'needs_rotation' ),
+			array_keys( $entries[0] )
+		);
+		$this->assertSame( 'myplugin/api-key', $entries[0]['name'] );
+	}
+
+	/**
+	 * Never a value, under any circumstance – the entire justification for this
+	 * function existing depends on it.
+	 */
+	public function test_never_returns_a_value(): void {
+		wp_set_secret( 'myplugin/api-key', 'a-plaintext-value-that-must-not-leak' );
+
+		$dump = wp_json_encode( wp_list_secrets() );
+
+		$this->assertIsString( $dump );
+		$this->assertStringNotContainsString( 'a-plaintext-value-that-must-not-leak', $dump );
+	}
+
+	public function test_fingerprint_matches_the_secrets_own_fingerprint(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$secret  = wp_get_secret( 'myplugin/api-key' );
+		$entries = wp_list_secrets();
+
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+		$this->assertIsArray( $entries );
+		$this->assertSame( $secret->fingerprint(), $entries[0]['fingerprint'] );
+	}
+
+	public function test_has_previous_is_false_before_a_rotation_and_true_after(): void {
+		wp_set_secret( 'myplugin/api-key', 'first-value' );
+
+		$before = wp_list_secrets();
+
+		$this->assertIsArray( $before );
+		$this->assertFalse( $before[0]['has_previous'] );
+
+		wp_set_secret( 'myplugin/api-key', 'second-value' );
+
+		$after = wp_list_secrets();
+
+		$this->assertIsArray( $after );
+		$this->assertTrue( $after[0]['has_previous'] );
+	}
+
+	public function test_needs_rotation_reflects_the_current_slots_flag(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$record = get_option( '_wp_secret_myplugin/api-key' );
+
+		$this->assertIsArray( $record );
+		$this->assertArrayHasKey( 'current', $record );
+		$this->assertIsArray( $record['current'] );
+
+		$record['current']['needs_rotation'] = true;
+		update_option( '_wp_secret_myplugin/api-key', $record, false );
+
+		$entries = wp_list_secrets();
+
+		$this->assertIsArray( $entries );
+		$this->assertTrue( $entries[0]['needs_rotation'] );
+	}
+
+	public function test_filters_by_namespace(): void {
+		wp_set_secret( 'pluginone/key', 'value' );
+		wp_set_secret( 'plugintwo/key', 'value' );
+
+		$entries = wp_list_secrets( 'pluginone' );
+
+		$this->assertIsArray( $entries );
+		$this->assertSame( array( 'pluginone/key' ), wp_list_pluck( $entries, 'name' ) );
+	}
+
+	public function test_empty_namespace_returns_everything(): void {
+		wp_set_secret( 'pluginone/key', 'value' );
+		wp_set_secret( 'plugintwo/key', 'value' );
+
+		$entries = wp_list_secrets( '' );
+
+		$this->assertIsArray( $entries );
+		$this->assertCount( 2, $entries );
+	}
+
+	public function test_namespace_does_not_match_a_prefix_of_a_different_namespace(): void {
+		// 'plugin' must not match 'pluginone/key' – only a full "namespace/" prefix.
+		wp_set_secret( 'pluginone/key', 'value' );
+
+		$this->assertSame( array(), wp_list_secrets( 'plugin' ) );
+	}
+
+	/**
+	 * A corrupted record must still appear in the list – Site Health's
+	 * undecryptable-secrets check depends on being able to see it exists.
+	 */
+	public function test_a_corrupt_record_is_still_listed_with_blank_metadata(): void {
+		update_option( '_wp_secret_myplugin/corrupt', 'not a record at all', false );
+
+		$entries = wp_list_secrets();
+
+		$this->assertIsArray( $entries );
+		$this->assertCount( 1, $entries );
+		$this->assertSame( 'myplugin/corrupt', $entries[0]['name'] );
+		$this->assertSame( '', $entries[0]['fingerprint'] );
+		$this->assertFalse( $entries[0]['has_previous'] );
+	}
+
+	public function test_does_not_list_network_scope_secrets(): void {
+		wp_set_secret( 'myplugin/site-only', 'value' );
+		_wp_secrets_set( 'myplugin/network-only', 'value', true );
+
+		$entries = wp_list_secrets();
+
+		$this->assertIsArray( $entries );
+		$this->assertSame( array( 'myplugin/site-only' ), wp_list_pluck( $entries, 'name' ) );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/wpSecret.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/wpSecret.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/wpSecret.php	(revision 64337)
@@ -0,0 +1,348 @@
+<?php
+/**
+ * Tests for WP_Secret.
+ *
+ * @package SecretsAPI
+ */
+
+/**
+ * Masking, serialization refusal, and vault lifecycle for WP_Secret.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_WPSecret extends WP_UnitTestCase {
+
+	use WP_Secrets_Assertions;
+
+	const PLAINTEXT   = 'sk_live_super_secret_value';
+	const NAME        = 'myplugin/api-key';
+	const FINGERPRINT = 'abc123def456';
+
+	/**
+	 * @return WP_Secret
+	 */
+	private function make_secret() {
+		return new WP_Secret( self::NAME, self::PLAINTEXT, self::FINGERPRINT );
+	}
+
+	public function test_class_is_final(): void {
+		$reflection = new ReflectionClass( WP_Secret::class );
+
+		$this->assertTrue( $reflection->isFinal() );
+	}
+
+	public function test_implements_json_serializable(): void {
+		$this->assertInstanceOf( JsonSerializable::class, $this->make_secret() );
+	}
+
+	public function test_reveal_returns_the_exact_plaintext(): void {
+		$secret = $this->make_secret();
+
+		$this->assertSame( self::PLAINTEXT, $secret->reveal() );
+	}
+
+	public function test_fingerprint_returns_the_exact_fingerprint(): void {
+		$secret = $this->make_secret();
+
+		$this->assertSame( self::FINGERPRINT, $secret->fingerprint() );
+	}
+
+	public function test_get_name_returns_the_exact_name(): void {
+		$secret = $this->make_secret();
+
+		$this->assertSame( self::NAME, $secret->get_name() );
+	}
+
+	public function test_constructor_rejects_a_non_string_value(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		new WP_Secret( self::NAME, 12345, self::FINGERPRINT ); // @phpstan-ignore argument.type (Intentionally passing a non-string value.)
+	}
+
+	public function test_constructor_rejects_an_empty_name(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		new WP_Secret( '', self::PLAINTEXT, self::FINGERPRINT );
+	}
+
+	public function test_constructor_rejects_an_empty_fingerprint(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		new WP_Secret( self::NAME, self::PLAINTEXT, '' );
+	}
+
+	/**
+	 * Every representation short of reveal() must exclude the plaintext.
+	 *
+	 * @dataProvider data_masking_surfaces
+	 *
+	 * @param callable( WP_Secret ): ( string|false ) $callback Renders the secret through one surface.
+	 */
+	public function test_masking_surfaces_never_contain_the_plaintext( $callback ): void {
+		$secret = $this->make_secret();
+
+		$output = $callback( $secret );
+
+		$this->assertIsString( $output );
+		$this->assertStringNotContainsString( self::PLAINTEXT, $output );
+	}
+
+	/**
+	 * @return array<string, array{0: callable( WP_Secret ): ( string|false )}>
+	 */
+	public function data_masking_surfaces() {
+		return array(
+			'(string) cast'        => array(
+				function ( WP_Secret $secret ) {
+					return (string) $secret;
+				},
+			),
+			'string interpolation' => array(
+				function ( WP_Secret $secret ) {
+					return "{$secret}";
+				},
+			),
+			'json_encode'          => array(
+				function ( WP_Secret $secret ) {
+					return json_encode( $secret );
+				},
+			),
+			'var_dump'             => array(
+				function ( WP_Secret $secret ) {
+					ob_start();
+					var_dump( $secret );
+
+					return ob_get_clean();
+				},
+			),
+			'print_r'              => array(
+				function ( WP_Secret $secret ) {
+					return print_r( $secret, true );
+				},
+			),
+			'var_export'           => array(
+				function ( WP_Secret $secret ) {
+					return var_export( $secret, true );
+				},
+			),
+			'error_log'            => array(
+				function ( WP_Secret $secret ) {
+					$file = tempnam( sys_get_temp_dir(), 'wp-secret-test-' );
+					error_log( $secret, 3, $file );
+					$contents = file_get_contents( $file );
+					unlink( $file );
+
+					return $contents;
+				},
+			),
+		);
+	}
+
+	public function test_to_string_yields_the_masked_placeholder(): void {
+		$secret = $this->make_secret();
+
+		$this->assertSame( '[secret:' . self::NAME . ']', (string) $secret );
+	}
+
+	public function test_json_encode_yields_the_masked_placeholder(): void {
+		$secret = $this->make_secret();
+
+		// json_encode() escapes '/' by default; decode rather than compare raw JSON.
+		$json = json_encode( $secret );
+
+		$this->assertIsString( $json );
+		$this->assertSame( '[secret:' . self::NAME . ']', json_decode( $json ) );
+	}
+
+	public function test_var_dump_yields_the_masked_placeholder(): void {
+		$secret = $this->make_secret();
+
+		ob_start();
+		var_dump( $secret );
+		$output = ob_get_clean();
+
+		$this->assertStringContainsString( '[secret:' . self::NAME . ']', $output );
+	}
+
+	public function test_serialize_throws(): void {
+		$secret = $this->make_secret();
+
+		$this->expectException( LogicException::class );
+
+		serialize( $secret );
+	}
+
+	public function test_clone_throws(): void {
+		$secret = $this->make_secret();
+
+		$this->expectException( LogicException::class );
+
+		$clone = clone $secret;
+	}
+
+	/**
+	 * Invoked directly via reflection, rather than through serialize()/unserialize(),
+	 * because __serialize() alone shadows __sleep() and vice versa depending on which
+	 * pair PHP's engine prefers – this proves each of the four refuses independently.
+	 *
+	 * @dataProvider data_refusing_magic_methods
+	 *
+	 * @param string            $method Magic method name.
+	 * @param array<int, mixed> $args   Arguments to invoke it with.
+	 */
+	public function test_serialization_magic_methods_throw_directly( $method, $args ): void {
+		$secret     = $this->make_secret();
+		$reflection = new ReflectionMethod( $secret, $method );
+		if ( PHP_VERSION_ID < 80100 ) {
+			$reflection->setAccessible( true );
+		}
+
+		$this->expectException( LogicException::class );
+
+		$reflection->invokeArgs( $secret, $args );
+	}
+
+	/**
+	 * @return array<string, array{0: string, 1: array<int, mixed>}>
+	 */
+	public function data_refusing_magic_methods() {
+		return array(
+			'__sleep'       => array( '__sleep', array() ),
+			'__wakeup'      => array( '__wakeup', array() ),
+			'__serialize'   => array( '__serialize', array() ),
+			'__unserialize' => array( '__unserialize', array( array() ) ),
+		);
+	}
+
+	public function test_destructing_the_last_reference_removes_it_from_the_vault(): void {
+		$secret = $this->make_secret();
+		$id     = spl_object_id( $secret );
+
+		$vault_property = new ReflectionProperty( WP_Secret::class, 'vault' );
+		if ( PHP_VERSION_ID < 80100 ) {
+			$vault_property->setAccessible( true );
+		}
+
+		$vault = $vault_property->getValue();
+
+		$this->assertIsArray( $vault );
+		$this->assertArrayHasKey( $id, $vault );
+
+		unset( $secret );
+
+		$vault = $vault_property->getValue();
+
+		$this->assertIsArray( $vault );
+		$this->assertArrayNotHasKey( $id, $vault );
+	}
+
+	/**
+	 * Nothing plaintext enters the object cache.
+	 *
+	 * The mechanism differs by backend and this asserts the outcome rather than the
+	 * route: core's non-persistent cache clones on set, which __clone() refuses,
+	 * while a persistent backend (Redis, Memcached) serializes instead, which
+	 * __serialize() refuses. Both are LogicException, so both are covered here
+	 * without the test having to know which cache is installed.
+	 */
+	public function test_wp_cache_set_of_a_secret_is_refused(): void {
+		$secret = $this->make_secret();
+
+		$this->expectException( LogicException::class );
+
+		wp_cache_set( 'secret', $secret, 'secrets-test' );
+	}
+
+	/**
+	 * The independent second layer, not a restatement of the test above.
+	 *
+	 * Verified by neutering __clone() and __serialize() and re-running: the test
+	 * above fails, this one still passes. That is the point – this holds because
+	 * the plaintext is not a property of the object, so even a clone or a
+	 * serialization that somehow got through would carry nothing to leak. It is a
+	 * regression guard against a future change that moves the plaintext back into a
+	 * declared property, which would defeat the masking design without breaking any
+	 * of the magic-method tests.
+	 */
+	public function test_nothing_plaintext_survives_a_refused_cache_write(): void {
+		$secret = $this->make_secret();
+
+		try {
+			wp_cache_set( 'secret', $secret, 'secrets-test' );
+		} catch ( LogicException $e ) {
+			unset( $e );
+		}
+
+		$cached = wp_cache_get( 'secret', 'secrets-test' );
+
+		$this->assertNeverContainsPlaintext( self::PLAINTEXT, $cached );
+	}
+
+	public function test_two_instances_do_not_share_a_vault_slot(): void {
+		$a = new WP_Secret( 'plugin/a', 'value-a', 'fp-a' );
+		$b = new WP_Secret( 'plugin/b', 'value-b', 'fp-b' );
+
+		$this->assertSame( 'value-a', $a->reveal() );
+		$this->assertSame( 'value-b', $b->reveal() );
+
+		unset( $a );
+
+		$this->assertSame( 'value-b', $b->reveal() );
+	}
+
+	// – withheld secrets (provider does not release the value) ---------------
+
+	/**
+	 * A secret a provider can name and fingerprint but will not hand to PHP: an
+	 * HSM signing key, a brokered credential. Everything except reveal() behaves
+	 * normally, which is the point – it still lists, still fingerprints, and
+	 * still masks itself everywhere.
+	 */
+	public function test_withheld_reveal_returns_the_providers_reason(): void {
+		$reason = new WP_Error( 'provider_withholds_value', 'This key never leaves the HSM.' );
+		$secret = WP_Secret::withheld( 'myplugin/signing-key', 'abc123', $reason );
+
+		$revealed = $secret->reveal();
+
+		$this->assertWPError( $revealed );
+		$this->assertSame( 'provider_withholds_value', $revealed->get_error_code() );
+	}
+
+	public function test_withheld_still_reports_name_and_fingerprint(): void {
+		$secret = WP_Secret::withheld(
+			'myplugin/signing-key',
+			'abc123',
+			new WP_Error( 'provider_withholds_value', 'nope' )
+		);
+
+		$this->assertSame( 'myplugin/signing-key', $secret->get_name() );
+		$this->assertSame( 'abc123', $secret->fingerprint() );
+	}
+
+	/**
+	 * The masking guarantees are not weakened by the withheld path: there is no
+	 * plaintext to leak, and the error reason must not leak either.
+	 */
+	public function test_withheld_masks_like_any_other_secret(): void {
+		$secret = WP_Secret::withheld(
+			'myplugin/signing-key',
+			'abc123',
+			new WP_Error( 'provider_withholds_value', 'UNIQUE-REASON-CANARY-4b1c' )
+		);
+
+		$printed = print_r( $secret, true );
+
+		$this->assertStringNotContainsString( 'UNIQUE-REASON-CANARY-4b1c', $printed );
+		$this->assertStringContainsString( '[secret:myplugin/signing-key]', (string) $secret );
+	}
+
+	/**
+	 * An ordinary secret is unaffected: reveal() still returns the plaintext, and
+	 * the widened return type is not a behaviour change for the shipped provider.
+	 */
+	public function test_an_ordinary_secret_still_reveals_a_plain_string(): void {
+		$secret = new WP_Secret( 'myplugin/api-key', 'sk_live_value', 'fingerprint' );
+
+		$this->assertSame( 'sk_live_value', $secret->reveal() );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/wpSecretVersion.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/wpSecretVersion.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/wpSecretVersion.php	(revision 64337)
@@ -0,0 +1,30 @@
+<?php
+/**
+ * @group secrets
+ */
+class Tests_Secrets_WPSecretVersion extends WP_UnitTestCase {
+
+	public function test_current_and_previous_are_distinct_strings(): void {
+		$this->assertIsString( WP_Secret_Version::CURRENT );
+		$this->assertIsString( WP_Secret_Version::PREVIOUS );
+		$this->assertNotSame( WP_Secret_Version::CURRENT, WP_Secret_Version::PREVIOUS );
+	}
+
+	public function test_class_is_final(): void {
+		$reflection = new ReflectionClass( WP_Secret_Version::class );
+
+		$this->assertTrue( $reflection->isFinal() );
+	}
+
+	public function test_class_declares_no_constructor(): void {
+		$reflection = new ReflectionClass( WP_Secret_Version::class );
+
+		$this->assertNull( $reflection->getConstructor() );
+	}
+
+	public function test_class_declares_exactly_two_constants(): void {
+		$reflection = new ReflectionClass( WP_Secret_Version::class );
+
+		$this->assertCount( 2, $reflection->getConstants() );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/wpSecretsCipher.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/wpSecretsCipher.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/wpSecretsCipher.php	(revision 64337)
@@ -0,0 +1,395 @@
+<?php
+/**
+ * Tests for WP_Secrets_Cipher.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_WPSecretsCipher extends WP_UnitTestCase {
+
+	const NAME = 'myplugin/api-key';
+
+	/**
+	 * Invokes a private method through reflection.
+	 *
+	 * @param object            $instance Object to invoke the method on.
+	 * @param string            $method   Method name.
+	 * @param array<int, mixed> $args     Positional arguments.
+	 * @return mixed Whatever the method returns.
+	 */
+	private function invoke_private( $instance, $method, array $args = array() ) {
+		$reflection = new ReflectionMethod( $instance, $method );
+		if ( PHP_VERSION_ID < 80100 ) {
+			$reflection->setAccessible( true );
+		}
+
+		return $reflection->invokeArgs( $instance, $args );
+	}
+
+	/**
+	 * Builds a 32-byte master key out of one repeated byte.
+	 *
+	 * @param int $byte Byte value to repeat.
+	 * @return string
+	 */
+	private function master_key( $byte = 0x11 ) {
+		return str_repeat( chr( $byte ), 32 );
+	}
+
+	public function test_round_trips_a_value(): void {
+		$cipher = new WP_Secrets_Cipher();
+		$record = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'sk_live_secret' );
+
+		$this->assertIsArray( $record );
+
+		$plaintext = $cipher->decrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, $record );
+
+		$this->assertSame( 'sk_live_secret', $plaintext );
+	}
+
+	public function test_round_trips_an_empty_string_value(): void {
+		$cipher = new WP_Secrets_Cipher();
+		$record = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, '' );
+
+		$plaintext = $cipher->decrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, $record );
+
+		$this->assertSame( '', $plaintext );
+	}
+
+	public function test_round_trips_under_network_scope(): void {
+		$cipher = new WP_Secrets_Cipher();
+		$record = $cipher->encrypt_value( $this->master_key(), 'network', 0, self::NAME, WP_Secret_Version::CURRENT, 'network-secret' );
+
+		$plaintext = $cipher->decrypt_value( $this->master_key(), 'network', 0, self::NAME, WP_Secret_Version::CURRENT, $record );
+
+		$this->assertSame( 'network-secret', $plaintext );
+	}
+
+	public function test_encrypt_rejects_a_non_string_value(): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$result = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, array() ); // @phpstan-ignore argument.type (Intentionally passing a non-string plaintext.)
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() );
+	}
+
+	/**
+	 * @dataProvider data_bad_master_keys
+	 *
+	 * @param string $bad_key Master key the cipher must refuse.
+	 */
+	public function test_encrypt_rejects_a_bad_master_key( $bad_key ): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$result = $cipher->encrypt_value( $bad_key, 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'value' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() );
+	}
+
+	/**
+	 * @return array<string, array{0: string}>
+	 */
+	public function data_bad_master_keys() {
+		return array(
+			'too short' => array( str_repeat( 'a', 31 ) ),
+			'too long'  => array( str_repeat( 'a', 33 ) ),
+			'empty'     => array( '' ),
+		);
+	}
+
+	public function test_encrypt_propagates_an_invalid_name(): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$result = $cipher->encrypt_value( $this->master_key(), 'site', 1, 'Not A Valid Name', WP_Secret_Version::CURRENT, 'value' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() );
+	}
+
+	public function test_encrypt_reports_an_invalid_scope_as_a_wp_error(): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$this->setExpectedIncorrectUsage( 'WP_Secrets_Cipher::encrypt_value()/decrypt_value()' );
+
+		$result = $cipher->encrypt_value( $this->master_key(), 'bogus-scope', 1, self::NAME, WP_Secret_Version::CURRENT, 'value' ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.)
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() );
+	}
+
+	public function test_encrypt_reports_a_negative_site_id_as_a_wp_error(): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$this->setExpectedIncorrectUsage( 'WP_Secrets_Cipher::encrypt_value()/decrypt_value()' );
+
+		$result = $cipher->encrypt_value( $this->master_key(), 'site', -1, self::NAME, WP_Secret_Version::CURRENT, 'value' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() );
+	}
+
+	public function test_encrypt_reports_an_invalid_slot_as_a_wp_error(): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$this->setExpectedIncorrectUsage( 'WP_Secrets_Cipher::encrypt_value()/decrypt_value()' );
+
+		$result = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, 'not-a-real-slot', 'value' ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.)
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() );
+	}
+
+	/**
+	 * AAD binding: a record encrypted under one context must not decrypt under a
+	 * different one, for any single component of that context.
+	 *
+	 * @dataProvider data_mismatched_context
+	 *
+	 * @param string $scope   Scope to decrypt under.
+	 * @param int    $site_id Site id to decrypt under.
+	 * @param string $name    Secret name to decrypt under.
+	 * @param string $slot    Version slot to decrypt under.
+	 *
+	 * @phpstan-param 'site'|'network' $scope
+	 * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot
+	 */
+	public function test_decrypt_fails_when_context_does_not_match( $scope, $site_id, $name, $slot ): void {
+		$cipher = new WP_Secrets_Cipher();
+		$record = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'value' );
+
+		$result = $cipher->decrypt_value( $this->master_key(), $scope, $site_id, $name, $slot, $record );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_DECRYPTION_FAILED, $result->get_error_code() );
+	}
+
+	/**
+	 * @return array<string, array{0: string, 1: int, 2: string, 3: string}>
+	 */
+	public function data_mismatched_context() {
+		return array(
+			'different scope'   => array( 'network', 1, self::NAME, WP_Secret_Version::CURRENT ),
+			'different site id' => array( 'site', 2, self::NAME, WP_Secret_Version::CURRENT ),
+			'different name'    => array( 'site', 1, 'myplugin/other-key', WP_Secret_Version::CURRENT ),
+			'different slot'    => array( 'site', 1, self::NAME, WP_Secret_Version::PREVIOUS ),
+		);
+	}
+
+	public function test_decrypt_fails_under_a_different_master_key(): void {
+		$cipher = new WP_Secrets_Cipher();
+		$record = $cipher->encrypt_value( $this->master_key( 0x11 ), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'value' );
+
+		$result = $cipher->decrypt_value( $this->master_key( 0x22 ), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, $record );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_DECRYPTION_FAILED, $result->get_error_code() );
+	}
+
+	/**
+	 * @dataProvider data_tamperable_fields
+	 *
+	 * @param string $field Record field to corrupt.
+	 */
+	public function test_decrypt_fails_when_a_field_is_tampered( $field ): void {
+		$cipher = new WP_Secrets_Cipher();
+		$record = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'value' );
+
+		$this->assertIsArray( $record );
+		$this->assertArrayHasKey( $field, $record );
+
+		$raw = base64_decode( $record[ $field ], true );
+
+		$this->assertIsString( $raw );
+
+		$last             = strlen( $raw ) - 1;
+		$raw[ $last ]     = chr( ( ord( $raw[ $last ] ) + 1 ) % 256 );
+		$record[ $field ] = base64_encode( $raw );
+
+		$result = $cipher->decrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, $record );
+
+		$this->assertWPError( $result );
+	}
+
+	/**
+	 * @return array<string, array{0: string}>
+	 */
+	public function data_tamperable_fields() {
+		return array(
+			'ciphertext'       => array( 'ct' ),
+			'value nonce'      => array( 'nonce' ),
+			'wrapped data key' => array( 'dk' ),
+			'data key nonce'   => array( 'dk_nonce' ),
+		);
+	}
+
+	/**
+	 * @dataProvider data_malformed_records
+	 *
+	 * @param mixed $record Stored record the cipher must refuse.
+	 */
+	public function test_decrypt_rejects_a_malformed_record( $record ): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$result = $cipher->decrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, $record );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() );
+	}
+
+	/**
+	 * @return array<string, array{0: mixed}>
+	 */
+	public function data_malformed_records() {
+		return array(
+			'not an array'       => array( 'just a string' ),
+			'missing ct'         => array(
+				array(
+					'dk'       => 'x',
+					'dk_nonce' => 'x',
+					'nonce'    => 'x',
+				),
+			),
+			'ct not base64'      => array(
+				array(
+					'dk'       => base64_encode( 'x' ),
+					'dk_nonce' => base64_encode( str_repeat( 'x', 24 ) ),
+					'ct'       => 'not base64!!',
+					'nonce'    => base64_encode( str_repeat( 'x', 24 ) ),
+				),
+			),
+			'nonce wrong length' => array(
+				array(
+					'dk'       => base64_encode( 'x' ),
+					'dk_nonce' => base64_encode( str_repeat( 'x', 24 ) ),
+					'ct'       => base64_encode( 'x' ),
+					'nonce'    => base64_encode( 'short' ),
+				),
+			),
+		);
+	}
+
+	public function test_data_key_independence(): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$a = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'same-value' );
+		$b = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'same-value' );
+
+		$this->assertIsArray( $a );
+		$this->assertIsArray( $b );
+
+		$this->assertNotSame( $a['dk'], $b['dk'] );
+		$this->assertNotSame( $a['ct'], $b['ct'] );
+	}
+
+	public function test_nonce_uniqueness_across_many_writes(): void {
+		$cipher = new WP_Secrets_Cipher();
+		$nonces = array();
+
+		for ( $i = 0; $i < 50; $i++ ) {
+			$record = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'value' );
+
+			$this->assertIsArray( $record );
+
+			$nonces[] = $record['nonce'];
+			$nonces[] = $record['dk_nonce'];
+		}
+
+		$this->assertSame( count( $nonces ), count( array_unique( $nonces ) ) );
+	}
+
+	public function test_fingerprint_is_stable_for_the_same_inputs(): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$this->assertSame(
+			$cipher->fingerprint( $this->master_key(), 'same-value' ),
+			$cipher->fingerprint( $this->master_key(), 'same-value' )
+		);
+	}
+
+	public function test_fingerprint_differs_across_master_keys(): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$this->assertNotSame(
+			$cipher->fingerprint( $this->master_key( 0x11 ), 'same-value' ),
+			$cipher->fingerprint( $this->master_key( 0x22 ), 'same-value' )
+		);
+	}
+
+	public function test_fingerprint_differs_across_plaintexts(): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$this->assertNotSame(
+			$cipher->fingerprint( $this->master_key(), 'value-a' ),
+			$cipher->fingerprint( $this->master_key(), 'value-b' )
+		);
+	}
+
+	public function test_fingerprint_rejects_a_bad_master_key(): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$result = $cipher->fingerprint( 'too-short', 'value' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() );
+	}
+
+	public function test_fingerprint_rejects_a_non_string_value(): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$result = $cipher->fingerprint( $this->master_key(), array() ); // @phpstan-ignore argument.type (Intentionally passing a non-string plaintext.)
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() );
+	}
+
+	/**
+	 * Known-answer vector for sodium_crypto_kdf_derive_from_key(32, 1, 'wpsecfpr', ...)
+	 * followed by sodium_crypto_generichash(..., ..., 16), computed independently on
+	 * both PHP 7.4 and 8.5 and pinned here so an accidental change to either
+	 * primitive, the KDF subkey id, the context string, or the output length is
+	 * caught rather than silently changing every existing fingerprint.
+	 */
+	public function test_fingerprint_known_answer_vector(): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$fingerprint = $cipher->fingerprint( str_repeat( chr( 0x11 ), 32 ), 'sk_live_example_value' );
+
+		$this->assertSame( '62d94d88bf417f09171e8efb7eb30297', $fingerprint );
+	}
+
+	/**
+	 * Pins the exact AAD format. A reviewer reordering these fields, or a future
+	 * commit accidentally dropping the site id or slot, silently weakens the binding
+	 * this whole design leans on – this test makes that change loud instead.
+	 */
+	public function test_aad_format_known_answer(): void {
+		$cipher = new WP_Secrets_Cipher();
+
+		$aad = $this->invoke_private(
+			$cipher,
+			'build_aad',
+			array( WP_Secrets_Cipher::AAD_VALUE, 'site', 1, self::NAME, WP_Secret_Version::CURRENT )
+		);
+
+		$this->assertSame( 'wp-secrets-value-v1|site|1|myplugin/api-key|current', $aad );
+	}
+
+	/**
+	 * Raw-primitive canary, independent of this class: proves the exact
+	 * key/nonce/AAD/plaintext combination below produces this exact ciphertext under
+	 * the algorithm this API depends on. Encrypt_value() can't be driven through this
+	 * same fixed nonce (it always generates a fresh random one, correctly), so this
+	 * exists purely to catch a libsodium/sodium_compat behavior change or version
+	 * mismatch that our own round-trip tests, which always encrypt and decrypt with
+	 * the same implementation, would never surface.
+	 */
+	public function test_raw_aead_primitive_known_answer(): void {
+		$key    = str_repeat( chr( 0x22 ), 32 );
+		$nonce  = str_repeat( chr( 0x33 ), 24 );
+		$aad    = 'wp-secrets-value-v1|site|1|plugin/key|current';
+		$cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( 'hello world', $aad, $nonce, $key );
+
+		$this->assertSame( 'fa9f4801aae412e9d2746dddc65d7b3d5a689f4e89461caf54975b', bin2hex( $cipher ) );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php	(revision 64337)
@@ -0,0 +1,332 @@
+<?php
+/**
+ * Tests for WP_Secrets_Config_Key_Provider.
+ *
+ * Constants (WP_SECRETS_KEY, WP_SECRETS_KEY_PREVIOUS) that this class reads can only
+ * be defined once per PHP process, so any test that needs a specific value for them
+ * runs @runInSeparateProcess. LOGGED_IN_KEY/LOGGED_IN_SALT are already defined by the
+ * WordPress test suite's own bootstrap before any test of ours can run, in every
+ * process including an isolated one, so the salt-fallback path is exercised against
+ * whatever the ambient harness defines them as rather than a value this suite picks.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_WPSecretsConfigKeyProvider extends WP_UnitTestCase {
+
+	/**
+	 * Calls a private method on an instance.
+	 *
+	 * @param object            $instance Object to call the method on.
+	 * @param string            $method   Method name.
+	 * @param array<int, mixed> $args     Positional arguments.
+	 * @return mixed Whatever the method returns.
+	 */
+	private function invoke_private( object $instance, string $method, array $args = array() ) {
+		$reflection = new ReflectionMethod( $instance, $method );
+		if ( PHP_VERSION_ID < 80100 ) {
+			$reflection->setAccessible( true );
+		}
+
+		return $reflection->invokeArgs( $instance, $args );
+	}
+
+	public function test_implements_the_keyring_interface(): void {
+		$this->assertInstanceOf( WP_Secrets_Keyring::class, new WP_Secrets_Config_Key_Provider() ); // @phpstan-ignore method.alreadyNarrowedType (Pins the interface the class declares, so removing it fails a test.)
+	}
+
+	public function test_wrap_then_unwrap_round_trips(): void {
+		$provider = new WP_Secrets_Config_Key_Provider();
+		$material = random_bytes( 32 );
+
+		$wrapped = $provider->wrap( $material );
+		$this->assertIsString( $wrapped );
+
+		$unwrapped = $provider->unwrap( $wrapped );
+		$this->assertSame( $material, $unwrapped );
+	}
+
+	public function test_wrap_rejects_an_empty_string(): void {
+		$provider = new WP_Secrets_Config_Key_Provider();
+
+		$result = $provider->wrap( '' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() );
+	}
+
+	public function test_two_wraps_of_the_same_material_produce_different_output(): void {
+		$provider = new WP_Secrets_Config_Key_Provider();
+		$material = random_bytes( 32 );
+
+		$this->assertNotSame( $provider->wrap( $material ), $provider->wrap( $material ) );
+	}
+
+	public function test_unwrap_rejects_an_empty_string(): void {
+		$provider = new WP_Secrets_Config_Key_Provider();
+
+		$result = $provider->unwrap( '' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() );
+	}
+
+	public function test_unwrap_rejects_garbage_base64(): void {
+		$provider = new WP_Secrets_Config_Key_Provider();
+
+		$result = $provider->unwrap( 'not valid base64 at all!!!' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() );
+	}
+
+	public function test_unwrap_rejects_data_too_short_to_contain_a_nonce(): void {
+		$provider = new WP_Secrets_Config_Key_Provider();
+
+		$result = $provider->unwrap( base64_encode( 'x' ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() );
+	}
+
+	public function test_unwrap_rejects_a_tampered_ciphertext(): void {
+		$provider = new WP_Secrets_Config_Key_Provider();
+		$wrapped  = $provider->wrap( random_bytes( 32 ) );
+		$this->assertIsString( $wrapped );
+
+		$raw = base64_decode( $wrapped, true );
+		$this->assertIsString( $raw );
+
+		$tampered_raw               = $raw;
+		$last_byte                  = strlen( $tampered_raw ) - 1;
+		$tampered_raw[ $last_byte ] = chr( ( ord( $tampered_raw[ $last_byte ] ) + 1 ) % 256 );
+
+		$result = $provider->unwrap( base64_encode( $tampered_raw ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() );
+	}
+
+	public function test_get_key_source_reports_the_salt_fallback_when_wp_secrets_key_is_unset(): void {
+		if ( 'put your unique phrase here' === LOGGED_IN_KEY || 'put your unique phrase here' === LOGGED_IN_SALT ) {
+			$this->markTestSkipped( 'Needs real LOGGED_IN_KEY and LOGGED_IN_SALT values; wp-tests-config-sample.php ships placeholders.' );
+		}
+
+		$provider = new WP_Secrets_Config_Key_Provider();
+
+		$this->assertStringContainsString( 'LOGGED_IN_KEY', $provider->get_key_source() );
+	}
+
+	/**
+	 * @dataProvider data_canonical_base64_32
+	 *
+	 * @param string|int $value    Candidate value.
+	 * @param bool       $expected Whether it is the canonical base64 encoding of 32 bytes.
+	 */
+	public function test_is_canonical_base64_32( $value, $expected ): void {
+		$provider = new WP_Secrets_Config_Key_Provider();
+
+		$this->assertSame( $expected, $this->invoke_private( $provider, 'is_canonical_base64_32', array( $value ) ) );
+	}
+
+	/**
+	 * @return array<string, array{0: string|int, 1: bool}>
+	 */
+	public function data_canonical_base64_32(): array {
+		return array(
+			'exactly 32 bytes, canonical encoding' => array( base64_encode( str_repeat( 'A', 32 ) ), true ),
+			'31 bytes'                             => array( base64_encode( str_repeat( 'A', 31 ) ), false ),
+			'33 bytes'                             => array( base64_encode( str_repeat( 'A', 33 ) ), false ),
+			'not base64 at all'                    => array( 'not-base64-32!!', false ),
+			'non-canonical padding'                => array( base64_encode( str_repeat( 'A', 32 ) ) . ' ', false ),
+			'empty string'                         => array( '', false ),
+			'not a string'                         => array( 12345, false ),
+		);
+	}
+
+	/**
+	 * @dataProvider data_usable_salt_values
+	 *
+	 * @param string|int|null $key      Candidate LOGGED_IN_KEY value.
+	 * @param string          $salt     Candidate LOGGED_IN_SALT value.
+	 * @param bool            $expected Whether the pair is usable as key material.
+	 */
+	public function test_are_usable_salt_values( $key, $salt, $expected ): void {
+		$provider = new WP_Secrets_Config_Key_Provider();
+
+		$this->assertSame( $expected, $this->invoke_private( $provider, 'are_usable_salt_values', array( $key, $salt ) ) );
+	}
+
+	/**
+	 * @return array<string, array{0: string|int|null, 1: string, 2: bool}>
+	 */
+	public function data_usable_salt_values(): array {
+		return array(
+			'both usable'             => array( 'a real key', 'a real salt', true ),
+			'key is the placeholder'  => array( 'put your unique phrase here', 'a real salt', false ),
+			'salt is the placeholder' => array( 'a real key', 'put your unique phrase here', false ),
+			'key is null (undefined)' => array( null, 'a real salt', false ),
+			'salt is empty'           => array( 'a real key', '', false ),
+			'key is not a string'     => array( 12345, 'a real salt', false ),
+		);
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_derives_raw_bytes_when_wp_secrets_key_is_canonical_base64_32(): void {
+		$raw = str_repeat( 'A', 32 );
+		define( 'WP_SECRETS_KEY', base64_encode( $raw ) );
+
+		$provider = new WP_Secrets_Config_Key_Provider();
+
+		$this->assertSame( $raw, $this->invoke_private( $provider, 'get_site_key' ) );
+	}
+
+	/**
+	 * Known-answer vector: sodium_crypto_generichash( 'not-base64-32!!', '', 32 ),
+	 * computed independently and pinned here so an accidental change to the
+	 * algorithm, its output length, or its inputs is caught rather than silently
+	 * changing what key every existing secret is wrapped under.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_derives_a_known_answer_via_the_legacy_interpretation(): void {
+		define( 'WP_SECRETS_KEY', 'not-base64-32!!' );
+
+		$provider = new WP_Secrets_Config_Key_Provider();
+		$key      = $this->invoke_private( $provider, 'get_site_key' );
+
+		$this->assertIsString( $key );
+		$this->assertSame(
+			'db0aa0426b1dfaecc1878c103914462b0066eca0cdd050654a179588fed74b21',
+			bin2hex( $key )
+		);
+	}
+
+	/**
+	 * A base64 string that decodes cleanly but not to 32 bytes must fall through to
+	 * the legacy (hashed) interpretation rather than being treated as raw key
+	 * material of the wrong length.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_wrong_length_base64_falls_through_to_legacy_interpretation(): void {
+		$sixteen_bytes = base64_encode( str_repeat( 'B', 16 ) );
+		define( 'WP_SECRETS_KEY', $sixteen_bytes );
+
+		$provider = new WP_Secrets_Config_Key_Provider();
+		$key      = $this->invoke_private( $provider, 'get_site_key' );
+
+		$this->assertSame(
+			sodium_crypto_generichash( $sixteen_bytes, '', 32 ),
+			$key
+		);
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_non_string_wp_secrets_key_is_unavailable(): void {
+		define( 'WP_SECRETS_KEY', 424242 );
+
+		$provider = new WP_Secrets_Config_Key_Provider();
+		$result   = $this->invoke_private( $provider, 'get_site_key' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_get_key_source_reports_base64_form(): void {
+		define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'A', 32 ) ) );
+
+		$provider = new WP_Secrets_Config_Key_Provider();
+
+		$this->assertStringContainsString( 'base64', $provider->get_key_source() );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_get_key_source_reports_legacy_form(): void {
+		define( 'WP_SECRETS_KEY', 'not-base64-32!!' );
+
+		$provider = new WP_Secrets_Config_Key_Provider();
+
+		$this->assertStringContainsString( 'legacy', $provider->get_key_source() );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_use_previous_key_reads_the_previous_constant(): void {
+		define( 'WP_SECRETS_KEY_PREVIOUS', base64_encode( str_repeat( 'C', 32 ) ) );
+
+		$provider = new WP_Secrets_Config_Key_Provider( true );
+
+		$this->assertSame( str_repeat( 'C', 32 ), $this->invoke_private( $provider, 'get_site_key' ) );
+	}
+
+	/**
+	 * Defining only the previous-key constant must not affect the current-key
+	 * provider: it should still fall through to the salt fallback.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_previous_key_constant_does_not_leak_into_the_current_key_path(): void {
+		if ( 'put your unique phrase here' === LOGGED_IN_KEY || 'put your unique phrase here' === LOGGED_IN_SALT ) {
+			$this->markTestSkipped( 'Needs real LOGGED_IN_KEY and LOGGED_IN_SALT values; wp-tests-config-sample.php ships placeholders.' );
+		}
+
+		define( 'WP_SECRETS_KEY_PREVIOUS', base64_encode( str_repeat( 'C', 32 ) ) );
+
+		$provider = new WP_Secrets_Config_Key_Provider( false );
+		$key      = $this->invoke_private( $provider, 'get_site_key' );
+
+		$this->assertNotWPError( $key );
+		$this->assertNotSame( str_repeat( 'C', 32 ), $key );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_use_previous_key_without_the_constant_defined_is_unavailable(): void {
+		$provider = new WP_Secrets_Config_Key_Provider( true );
+		$result   = $this->invoke_private( $provider, 'get_site_key' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() );
+	}
+
+	/**
+	 * A key rotation unwraps under the outgoing key and wraps under the incoming
+	 * one. This proves the two providers actually disagree when they should.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_old_and_new_key_providers_are_not_interchangeable(): void {
+		define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'D', 32 ) ) );
+		define( 'WP_SECRETS_KEY_PREVIOUS', base64_encode( str_repeat( 'E', 32 ) ) );
+
+		$old_provider = new WP_Secrets_Config_Key_Provider( true );
+		$new_provider = new WP_Secrets_Config_Key_Provider( false );
+
+		$wrapped_under_old = $old_provider->wrap( random_bytes( 32 ) );
+		$this->assertIsString( $wrapped_under_old );
+
+		$this->assertWPError( $new_provider->unwrap( $wrapped_under_old ) );
+		$this->assertIsString( $old_provider->unwrap( $wrapped_under_old ) );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/wpSecretsKeyManager.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/wpSecretsKeyManager.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/wpSecretsKeyManager.php	(revision 64337)
@@ -0,0 +1,466 @@
+<?php
+/**
+ * Tests for WP_Secrets_Key_Manager.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_WPSecretsKeyManager extends WP_UnitTestCase {
+
+	public function test_get_root_key_generates_and_persists_one(): void {
+		$manager = new WP_Secrets_Key_Manager();
+
+		$this->assertFalse( get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ) );
+
+		$root_key = $manager->get_root_key();
+
+		$this->assertIsString( $root_key );
+		$this->assertSame( 32, strlen( $root_key ) );
+		$this->assertNotFalse( get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ) );
+	}
+
+	public function test_get_root_key_is_stable_on_the_same_instance(): void {
+		$manager = new WP_Secrets_Key_Manager();
+
+		$this->assertSame( $manager->get_root_key(), $manager->get_root_key() );
+	}
+
+	public function test_get_root_key_persists_across_instances(): void {
+		$first  = new WP_Secrets_Key_Manager();
+		$root_a = $first->get_root_key();
+
+		$second = new WP_Secrets_Key_Manager();
+		$root_b = $second->get_root_key();
+
+		$this->assertSame( $root_a, $root_b );
+	}
+
+	public function test_get_root_key_uses_an_existing_value_rather_than_regenerating(): void {
+		$keyring = new WP_Secrets_Config_Key_Provider();
+		$known   = str_repeat( 'R', 32 );
+
+		update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $keyring->wrap( $known ) );
+
+		$manager = new WP_Secrets_Key_Manager( $keyring );
+
+		$this->assertSame( $known, $manager->get_root_key() );
+	}
+
+	public function test_get_master_key_matches_manual_derivation(): void {
+		$manager  = new WP_Secrets_Key_Manager();
+		$root_key = $manager->get_root_key();
+		$this->assertIsString( $root_key );
+
+		$expected = sodium_crypto_kdf_derive_from_key( 32, get_current_blog_id(), 'wpsecsit', $root_key );
+
+		$this->assertSame( $expected, $manager->get_master_key( 'site' ) );
+	}
+
+	public function test_get_master_key_differs_per_site_id(): void {
+		$manager = new WP_Secrets_Key_Manager();
+
+		$this->assertNotSame(
+			$manager->get_master_key( 'site', 5 ),
+			$manager->get_master_key( 'site', 6 )
+		);
+	}
+
+	public function test_get_master_key_site_and_network_scope_differ(): void {
+		$manager = new WP_Secrets_Key_Manager();
+
+		$this->assertNotSame(
+			$manager->get_master_key( 'site', 1 ),
+			$manager->get_master_key( 'network' )
+		);
+	}
+
+	public function test_get_master_key_network_scope_is_stable(): void {
+		$manager = new WP_Secrets_Key_Manager();
+
+		$this->assertSame(
+			$manager->get_master_key( 'network' ),
+			$manager->get_master_key( 'network' )
+		);
+	}
+
+	public function test_get_master_key_defaults_site_id_to_the_current_blog(): void {
+		$manager = new WP_Secrets_Key_Manager();
+
+		$this->assertSame(
+			$manager->get_master_key( 'site' ),
+			$manager->get_master_key( 'site', get_current_blog_id() )
+		);
+	}
+
+	public function test_get_master_key_reports_an_invalid_scope_as_a_wp_error(): void {
+		$manager = new WP_Secrets_Key_Manager();
+
+		$this->setExpectedIncorrectUsage( 'WP_Secrets_Key_Manager::get_master_key' );
+
+		$result = $manager->get_master_key( 'bogus' ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.)
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() );
+	}
+
+	/**
+	 * @dataProvider data_invalid_site_ids
+	 *
+	 * @param int $site_id A value that is not a usable blog id.
+	 */
+	public function test_get_master_key_reports_an_invalid_site_id_as_a_wp_error( $site_id ): void {
+		$manager = new WP_Secrets_Key_Manager();
+
+		$this->setExpectedIncorrectUsage( 'WP_Secrets_Key_Manager::get_master_key' );
+
+		$result = $manager->get_master_key( 'site', $site_id );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() );
+	}
+
+	/**
+	 * @return array<string, array{0: int}>
+	 */
+	public function data_invalid_site_ids(): array {
+		return array(
+			'zero'     => array( 0 ),
+			'negative' => array( -1 ),
+		);
+	}
+
+	/**
+	 * The root key is shared network-wide (one row via update_site_option()), so a
+	 * master key derived on one blog must be derivable identically on another –
+	 * this is the entire reason network secrets can be read from any blog. Requires
+	 * the multisite suite; skips under single-site since there is only one blog to
+	 * compare against.
+	 */
+	public function test_network_scope_master_key_is_identical_across_blogs(): void {
+		if ( ! is_multisite() ) {
+			$this->markTestSkipped( 'Requires multisite: proves the root key is shared network-wide.' );
+		}
+
+		$second_blog_id = self::factory()->blog->create();
+
+		$manager               = new WP_Secrets_Key_Manager();
+		$network_key_on_blog_1 = $manager->get_master_key( 'network' );
+
+		switch_to_blog( $second_blog_id );
+		$network_key_on_blog_2 = ( new WP_Secrets_Key_Manager() )->get_master_key( 'network' );
+		restore_current_blog();
+
+		$this->assertSame( $network_key_on_blog_1, $network_key_on_blog_2 );
+	}
+
+	/**
+	 * Site-scope master keys, by contrast, must differ per blog even though every
+	 * blog derives from the same shared root key.
+	 */
+	public function test_site_scope_master_key_differs_across_blogs(): void {
+		if ( ! is_multisite() ) {
+			$this->markTestSkipped( 'Requires multisite: proves per-blog cryptographic separation.' );
+		}
+
+		$second_blog_id = self::factory()->blog->create();
+
+		$manager            = new WP_Secrets_Key_Manager();
+		$site_key_on_blog_1 = $manager->get_master_key( 'site' );
+
+		switch_to_blog( $second_blog_id );
+		$site_key_on_blog_2 = ( new WP_Secrets_Key_Manager() )->get_master_key( 'site' );
+		restore_current_blog();
+
+		$this->assertNotSame( $site_key_on_blog_1, $site_key_on_blog_2 );
+	}
+
+	/**
+	 * Simulates single-site-to-multisite conversion: moves the wrapped root key
+	 * from its main-site option row into sitemeta, the way a fresh
+	 * WP_Secrets_Key_Manager finds it after `wp core multisite-convert`.
+	 *
+	 * Requires the multisite suite; skips under single-site since sitemeta and
+	 * the main site's options are the same table there.
+	 */
+	public function test_get_root_key_adopts_a_pre_conversion_root_key(): void {
+		if ( ! is_multisite() ) {
+			$this->markTestSkipped( 'Requires multisite: proves the main-site option row is adopted.' );
+		}
+
+		$manager  = new WP_Secrets_Key_Manager();
+		$root_key = $manager->get_root_key();
+
+		$main_site_id = get_main_site_id();
+		$wrapped      = get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION );
+
+		delete_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION );
+		update_blog_option( $main_site_id, WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $wrapped );
+
+		$fresh_manager = new WP_Secrets_Key_Manager();
+		$adopted_key   = $fresh_manager->get_root_key();
+
+		$this->assertIsString( $root_key );
+		$this->assertIsString( $adopted_key );
+		$this->assertTrue( hash_equals( $root_key, $adopted_key ) );
+		$this->assertNotFalse( get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ) );
+		$this->assertFalse( get_blog_option( $main_site_id, WP_Secrets_Key_Manager::ROOT_KEY_OPTION ) );
+	}
+
+	/**
+	 * Requires the multisite suite; skips under single-site for the same reason
+	 * as the adoption test above.
+	 */
+	public function test_secret_written_before_conversion_still_decrypts(): void {
+		if ( ! is_multisite() ) {
+			$this->markTestSkipped( 'Requires multisite: proves secrets survive conversion.' );
+		}
+
+		$main_site_id = get_main_site_id();
+
+		$this->assertTrue( wp_set_secret( 'secrets-api/pre-conversion', 'shh' ) );
+
+		$wrapped = get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION );
+		delete_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION );
+		update_blog_option( $main_site_id, WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $wrapped );
+
+		$secret = wp_get_secret( 'secrets-api/pre-conversion' );
+
+		$this->assertNotWPError( $secret );
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+		$this->assertSame( 'shh', $secret->reveal() );
+	}
+
+	/**
+	 * Requires the multisite suite; skips under single-site for the same reason
+	 * as the adoption test above.
+	 */
+	public function test_rotate_site_key_works_after_conversion_before_any_read(): void {
+		if ( ! is_multisite() ) {
+			$this->markTestSkipped( 'Requires multisite: proves rotation adopts a pre-conversion key too.' );
+		}
+
+		$main_site_id  = get_main_site_id();
+		$right_keyring = new WP_Secrets_Config_Key_Provider();
+		$manager       = new WP_Secrets_Key_Manager( $right_keyring );
+
+		$manager->get_root_key();
+		$master_before = $manager->get_master_key( 'site' );
+
+		$wrapped = get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION );
+		delete_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION );
+		update_blog_option( $main_site_id, WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $wrapped );
+
+		$fresh_manager = new WP_Secrets_Key_Manager( $right_keyring );
+		$rotated       = $fresh_manager->rotate_site_key( $right_keyring, $right_keyring );
+
+		$this->assertNotWPError( $rotated );
+		$this->assertTrue( $rotated );
+
+		$master_after = $fresh_manager->get_master_key( 'site' );
+
+		$this->assertSame( $master_before, $master_after );
+	}
+
+	public function test_rotate_fails_when_no_root_key_exists(): void {
+		$manager = new WP_Secrets_Key_Manager();
+		$keyring = new WP_Secrets_Config_Key_Provider();
+
+		$result = $manager->rotate_site_key( $keyring, $keyring );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code() );
+	}
+
+	public function test_rotate_fails_when_the_old_keyring_is_wrong(): void {
+		$right_keyring = new WP_Secrets_Config_Key_Provider();
+		$manager       = new WP_Secrets_Key_Manager( $right_keyring );
+		$manager->get_root_key();
+
+		$wrong_keyring = new class() implements WP_Secrets_Keyring {
+			/**
+			 * @param string $key_material Raw key material.
+			 * @return string
+			 */
+			public function wrap( string $key_material ) {
+				return base64_encode( $key_material );
+			}
+
+			/**
+			 * @param string $wrapped Wrapped value.
+			 * @return WP_Error
+			 */
+			public function unwrap( string $wrapped ) {
+				return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, 'wrong key' );
+			}
+
+			/**
+			 * @return string
+			 */
+			public function get_key_source(): string {
+				return 'test double';
+			}
+		};
+
+		$result = $manager->rotate_site_key( $wrong_keyring, $right_keyring );
+
+		$this->assertWPError( $result );
+	}
+
+	/**
+	 * A site-key rotation re-wraps the root key without changing its raw bytes, so
+	 * every derived master key – and therefore every stored secret – is unaffected.
+	 * This is the whole point of the envelope design, so it gets an end-to-end proof
+	 * rather than just testing rotate_site_key() in isolation.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_rotation_does_not_change_any_derived_master_key(): void {
+		define( 'WP_SECRETS_KEY_PREVIOUS', base64_encode( str_repeat( 'A', 32 ) ) );
+		define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'B', 32 ) ) );
+
+		$old_keyring = new WP_Secrets_Config_Key_Provider( true );
+		$new_keyring = new WP_Secrets_Config_Key_Provider( false );
+
+		$manager_under_old_key = new WP_Secrets_Key_Manager( $old_keyring );
+		$manager_under_old_key->get_root_key(); // Generates, wrapped under the old key.
+
+		$master_before = $manager_under_old_key->get_master_key( 'site', 7 );
+
+		$rotated = $manager_under_old_key->rotate_site_key( $old_keyring, $new_keyring );
+		$this->assertNotWPError( $rotated );
+
+		$manager_under_new_key = new WP_Secrets_Key_Manager( $new_keyring );
+		$master_after          = $manager_under_new_key->get_master_key( 'site', 7 );
+
+		$this->assertSame( $master_before, $master_after );
+
+		// The old keyring alone is no longer sufficient: the stored root key is now
+		// wrapped under the new key. Checked with a fresh manager instance, since
+		// $manager_under_old_key's own cache was correctly primed by the rotation
+		// it just performed and is not the thing under test here.
+		$fresh_manager_under_old_key = new WP_Secrets_Key_Manager( $old_keyring );
+		$this->assertWPError( $fresh_manager_under_old_key->get_root_key() );
+	}
+
+	public function test_unwrap_is_called_once_across_repeated_master_key_derivations(): void {
+		$mock = new Mock_Keyring();
+		$root = random_bytes( 32 );
+		update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $mock->wrap( $root ) );
+
+		$manager = new WP_Secrets_Key_Manager( $mock );
+
+		for ( $i = 0; $i < 5; $i++ ) {
+			$site_key = $manager->get_master_key( 'site', $i + 1 );
+			$this->assertIsString( $site_key );
+			$this->assertSame( 32, strlen( $site_key ) );
+
+			$network_key = $manager->get_master_key( 'network' );
+			$this->assertIsString( $network_key );
+			$this->assertSame( 32, strlen( $network_key ) );
+		}
+
+		$this->assertSame( 1, $mock->unwrap_call_count() );
+	}
+
+	/**
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_unwrap_is_called_once_across_many_secret_reads(): void {
+		$mock = new Mock_Keyring();
+		$root = random_bytes( 32 );
+		update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $mock->wrap( $root ) );
+
+		$GLOBALS['wp_secrets_keyring'] = $mock;
+
+		$this->assertNotWPError( wp_set_secret( 'conformance/root-key-cache', 'the-value' ) );
+
+		for ( $i = 0; $i < 10; $i++ ) {
+			$secret = wp_get_secret( 'conformance/root-key-cache' );
+			$this->assertInstanceOf( 'WP_Secret', $secret );
+			$this->assertSame( 'the-value', $secret->reveal() );
+		}
+
+		$this->assertSame( 1, $mock->unwrap_call_count() );
+	}
+
+	public function test_rotate_site_key_updates_the_cache_without_another_unwrap(): void {
+		$mock        = new Mock_Keyring();
+		$second_mock = new Mock_Keyring();
+		$root        = random_bytes( 32 );
+		update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $mock->wrap( $root ) );
+
+		$manager  = new WP_Secrets_Key_Manager( $mock );
+		$root_key = $manager->get_root_key();
+
+		$this->assertNotWPError( $manager->rotate_site_key( $mock, $second_mock ) );
+
+		$this->assertSame( $root_key, $manager->get_root_key() );
+		$this->assertSame( 1, $mock->unwrap_call_count() );
+		$this->assertSame( 0, $second_mock->unwrap_call_count() );
+	}
+
+	public function test_a_changed_wrapped_value_is_unwrapped_again_rather_than_served_from_cache(): void {
+		$mock       = new Mock_Keyring();
+		$root       = random_bytes( 32 );
+		$other_root = random_bytes( 32 );
+		update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $mock->wrap( $root ) );
+
+		$manager = new WP_Secrets_Key_Manager( $mock );
+		$this->assertSame( $root, $manager->get_root_key() );
+
+		update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $mock->wrap( $other_root ) );
+
+		$this->assertSame( $other_root, $manager->get_root_key() );
+		$this->assertSame( 2, $mock->unwrap_call_count() );
+	}
+
+	public function test_an_unwrap_error_is_not_cached(): void {
+		$mock = new Mock_Keyring();
+		$root = random_bytes( 32 );
+		update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $mock->wrap( $root ) );
+
+		$manager = new WP_Secrets_Key_Manager( $mock );
+
+		// Each call is assigned before it is asserted on: get_root_key() answers
+		// differently as the keyring's state changes, so no two calls are the
+		// same expression as far as a reader (or an analyzer) should assume.
+		$mock->configure_fail_unwrap( true );
+		$while_failing = $manager->get_root_key();
+		$this->assertWPError( $while_failing );
+
+		$mock->configure_fail_unwrap( false );
+		$after_recovery = $manager->get_root_key();
+		$this->assertSame( $root, $after_recovery );
+
+		$from_cache = $manager->get_root_key();
+		$this->assertSame( $root, $from_cache );
+		$this->assertSame( 2, $mock->unwrap_call_count() );
+	}
+
+	public function test_generate_root_key_primes_the_cache(): void {
+		$mock = new Mock_Keyring();
+
+		$manager = new WP_Secrets_Key_Manager( $mock );
+		$manager->get_root_key();
+		$manager->get_master_key( 'site' );
+
+		$this->assertSame( 0, $mock->unwrap_call_count() );
+		$this->assertSame( 1, $mock->wrap_call_count() );
+	}
+
+	public function test_the_returned_root_key_is_a_copy_the_caller_can_zero(): void {
+		$mock = new Mock_Keyring();
+		$root = random_bytes( 32 );
+		update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $mock->wrap( $root ) );
+
+		$manager = new WP_Secrets_Key_Manager( $mock );
+
+		$copy = $manager->get_root_key();
+		$this->assertIsString( $copy );
+		wp_secrets_memzero( $copy );
+
+		$this->assertSame( $root, $manager->get_root_key() );
+		$this->assertSame( 1, $mock->unwrap_call_count() );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/wpSecretsMemzero.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/wpSecretsMemzero.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/wpSecretsMemzero.php	(revision 64337)
@@ -0,0 +1,43 @@
+<?php
+/**
+ * @group secrets
+ */
+class Tests_Secrets_WpSecretsMemzero extends WP_UnitTestCase {
+
+	public function test_clears_a_plaintext_string(): void {
+		$value = 'a plaintext value';
+
+		wp_secrets_memzero( $value );
+
+		$this->assertNotSame( 'a plaintext value', $value );
+		$this->assertSame( '', $value );
+	}
+
+	public function test_leaves_non_string_values_untouched(): void {
+		$value = 42;
+
+		wp_secrets_memzero( $value ); // @phpstan-ignore argument.type (Intentionally passing a non-string value.)
+
+		$this->assertSame( 42, $value );
+	}
+
+	public function test_leaves_an_empty_string_untouched(): void {
+		$value = '';
+
+		wp_secrets_memzero( $value );
+
+		$this->assertSame( '', $value );
+	}
+
+	public function test_does_not_throw_when_the_value_is_shared(): void {
+		$value = 'shared plaintext';
+		$copy  = $value;
+
+		wp_secrets_memzero( $value );
+
+		$this->assertSame( '', $value );
+		// The shared copy is unaffected – see the docblock on wp_secrets_memzero():
+		// this is hygiene, not a guarantee, for exactly this reason.
+		$this->assertSame( 'shared plaintext', $copy ); // @phpstan-ignore method.alreadyNarrowedType (Whether the copy survives the scrub is decided by the engine at runtime, not by the types.)
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/wpSecretsOptionStore.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/wpSecretsOptionStore.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/wpSecretsOptionStore.php	(revision 64337)
@@ -0,0 +1,169 @@
+<?php
+/**
+ * Tests for WP_Secrets_Option_Store.
+ *
+ * @group secrets
+ */
+class Tests_Secrets_WPSecretsOptionStore extends WP_UnitTestCase {
+
+	/**
+	 * A well-formed single-slot record.
+	 *
+	 * @return array{v: int, current: array<string, int|string>}
+	 */
+	private function sample_record(): array {
+		return array(
+			'v'       => 1,
+			'current' => array(
+				'dk'          => 'x',
+				'dk_nonce'    => 'x',
+				'ct'          => 'x',
+				'nonce'       => 'x',
+				'fingerprint' => 'x',
+				'created'     => 12345,
+			),
+		);
+	}
+
+	public function test_implements_the_store_interface(): void {
+		$this->assertInstanceOf( WP_Secrets_Store::class, new WP_Secrets_Option_Store() ); // @phpstan-ignore method.alreadyNarrowedType (Pins the interface the class declares, so removing it fails a test.)
+	}
+
+	public function test_get_returns_null_for_an_absent_secret(): void {
+		$store = new WP_Secrets_Option_Store();
+
+		$this->assertNull( $store->get( 'myplugin/absent' ) );
+	}
+
+	public function test_set_then_get_round_trips_a_record_site_scope(): void {
+		$store  = new WP_Secrets_Option_Store();
+		$record = $this->sample_record();
+
+		$this->assertTrue( $store->set( 'myplugin/key', $record ) );
+		$this->assertSame( $record, $store->get( 'myplugin/key' ) );
+	}
+
+	public function test_set_then_get_round_trips_a_record_network_scope(): void {
+		$store  = new WP_Secrets_Option_Store();
+		$record = $this->sample_record();
+
+		$this->assertTrue( $store->set( 'myplugin/key', $record, true ) );
+		$this->assertSame( $record, $store->get( 'myplugin/key', true ) );
+	}
+
+	public function test_site_and_network_scope_do_not_collide(): void {
+		$store          = new WP_Secrets_Option_Store();
+		$site_record    = $this->sample_record();
+		$network_record = array_merge( $this->sample_record(), array( 'marker' => 'network' ) );
+
+		$store->set( 'myplugin/key', $site_record, false );
+		$store->set( 'myplugin/key', $network_record, true );
+
+		$this->assertSame( $site_record, $store->get( 'myplugin/key', false ) );
+		$this->assertSame( $network_record, $store->get( 'myplugin/key', true ) );
+	}
+
+	public function test_get_returns_an_error_when_the_stored_value_is_not_an_array(): void {
+		update_option( '_wp_secret_myplugin/key', 'not an array', false );
+
+		$store = new WP_Secrets_Option_Store();
+
+		$result = $store->get( 'myplugin/key' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() );
+	}
+
+	public function test_delete_removes_an_existing_record(): void {
+		$store = new WP_Secrets_Option_Store();
+		$store->set( 'myplugin/key', $this->sample_record() );
+
+		$this->assertTrue( $store->delete( 'myplugin/key' ) );
+		$this->assertNull( $store->get( 'myplugin/key' ) );
+	}
+
+	public function test_delete_on_an_absent_record_is_not_an_error(): void {
+		$store = new WP_Secrets_Option_Store();
+
+		$this->assertTrue( $store->delete( 'myplugin/never-existed' ) );
+	}
+
+	/**
+	 * @global wpdb $wpdb WordPress database abstraction object.
+	 */
+	public function test_site_scope_records_are_not_autoloaded(): void {
+		global $wpdb;
+
+		$store = new WP_Secrets_Option_Store();
+		$store->set( 'myplugin/key', $this->sample_record() );
+
+		$autoload = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT autoload FROM {$wpdb->options} WHERE option_name = %s",
+				'_wp_secret_myplugin/key'
+			)
+		);
+
+		$this->assertContains( $autoload, array( 'no', 'off' ) );
+	}
+
+	public function test_list_names_returns_stored_names_without_the_prefix(): void {
+		$store = new WP_Secrets_Option_Store();
+		$store->set( 'myplugin/one', $this->sample_record() );
+		$store->set( 'myplugin/two', $this->sample_record() );
+
+		$names = $store->list_names();
+
+		$this->assertIsArray( $names );
+		$this->assertContains( 'myplugin/one', $names );
+		$this->assertContains( 'myplugin/two', $names );
+	}
+
+	public function test_list_names_does_not_include_unrelated_options(): void {
+		update_option( 'completely_unrelated_option', 'value' );
+
+		$store = new WP_Secrets_Option_Store();
+		$store->set( 'myplugin/one', $this->sample_record() );
+
+		$names = $store->list_names();
+
+		$this->assertIsArray( $names );
+		$this->assertNotContains( 'completely_unrelated_option', $names );
+	}
+
+	public function test_list_names_does_not_include_network_scope_names(): void {
+		$store = new WP_Secrets_Option_Store();
+		$store->set( 'myplugin/site-only', $this->sample_record(), false );
+		$store->set( 'myplugin/network-only', $this->sample_record(), true );
+
+		$site_names    = $store->list_names( false );
+		$network_names = $store->list_names( true );
+
+		$this->assertIsArray( $site_names );
+		$this->assertIsArray( $network_names );
+		$this->assertContains( 'myplugin/site-only', $site_names );
+		$this->assertNotContains( 'myplugin/network-only', $site_names );
+		$this->assertContains( 'myplugin/network-only', $network_names );
+		$this->assertNotContains( 'myplugin/site-only', $network_names );
+	}
+
+	/**
+	 * '_' is a single-character wildcard in SQL LIKE. An option name with any other
+	 * character where the prefix's underscore falls must not match unless the prefix
+	 * is properly escaped before the query is built.
+	 */
+	public function test_list_names_escapes_the_underscore_wildcard(): void {
+		// Would match "_wp_secret_%" under an *unescaped* LIKE, since '_' matches
+		// any single character – proving esc_like() is actually applied.
+		update_option( '_wpXsecret_decoy/name', 'irrelevant value', false );
+
+		$store = new WP_Secrets_Option_Store();
+		$store->set( 'myplugin/real', $this->sample_record() );
+
+		$names = $store->list_names();
+
+		$this->assertIsArray( $names );
+		$this->assertContains( 'myplugin/real', $names );
+		$this->assertNotContains( 'decoy/name', $names );
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/wpSecretsValidateName.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/wpSecretsValidateName.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/wpSecretsValidateName.php	(revision 64337)
@@ -0,0 +1,107 @@
+<?php
+/**
+ * @group secrets
+ */
+class Tests_Secrets_WpSecretsValidateName extends WP_UnitTestCase {
+
+	/**
+	 * @dataProvider data_valid_names
+	 *
+	 * @param string $name A name that must be accepted.
+	 */
+	public function test_accepts_valid_names( $name ): void {
+		$this->assertTrue( wp_secrets_validate_name( $name ) );
+	}
+
+	/**
+	 * @return array<string, array{0: string}>
+	 */
+	public function data_valid_names(): array {
+		return array(
+			'simple'                      => array( 'myplugin/api-key' ),
+			'single character segments'   => array( 'a/b' ),
+			'hyphens'                     => array( 'my-plugin/my-secret-key' ),
+			'underscores'                 => array( 'my_plugin/my_secret_key' ),
+			'digits'                      => array( 'plugin123/key456' ),
+			'mixed hyphen and underscore' => array( 'a1-b2_c3/d4-e5_f6' ),
+			'consecutive separators'      => array( 'a--b/c__d' ),
+			'exactly the max length'      => array( str_repeat( 'a', 85 ) . '/' . str_repeat( 'b', 86 ) ),
+		);
+	}
+
+	/**
+	 * @dataProvider data_invalid_names
+	 *
+	 * @param string $name A name that must be rejected.
+	 */
+	public function test_rejects_invalid_names( $name ): void {
+		$result = wp_secrets_validate_name( $name );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() );
+	}
+
+	/**
+	 * An unnamespaced name is accepted, but reports through _doing_it_wrong().
+	 * It exists so code written against the Displace prototype – whose keyspace
+	 * was flat – can be ported one call site at a time rather than all at once.
+	 * See docs/decisions/0005-namespaces-are-not-access-control.md for the consequence.
+	 *
+	 * @dataProvider data_unnamespaced_names
+	 *
+	 * @param string $name An unnamespaced name that must be accepted.
+	 */
+	public function test_accepts_an_unnamespaced_name_but_reports_it( $name ): void {
+		$this->setExpectedIncorrectUsage( 'wp_secrets_validate_name' );
+
+		$this->assertTrue( wp_secrets_validate_name( $name ) );
+	}
+
+	/**
+	 * @return array<string, array{0: string}>
+	 */
+	public function data_unnamespaced_names(): array {
+		return array(
+			'simple'      => array( 'api_key' ),
+			'with hyphen' => array( 'api-key' ),
+			'digits'      => array( 'key2' ),
+			'at the max'  => array( str_repeat( 'a', WP_SECRETS_MAX_NAME_LENGTH ) ),
+		);
+	}
+
+	/**
+	 * The unnamespaced form relaxes the "exactly one slash" rule, not the
+	 * character rules – an unnamespaced name is still held to the same segment
+	 * pattern as a namespaced one.
+	 */
+	public function test_an_unnamespaced_name_still_obeys_the_character_rules(): void {
+		$result = wp_secrets_validate_name( 'Not_A_Valid_Name' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() );
+	}
+
+	/**
+	 * @return array<string, array{0: string}>
+	 */
+	public function data_invalid_names(): array {
+		return array(
+			'empty string'                 => array( '' ),
+			'two slashes'                  => array( 'too/many/slashes' ),
+			'uppercase, unnamespaced'      => array( 'NotAValidName' ),
+			'space, unnamespaced'          => array( 'not a valid name' ),
+			'empty namespace'              => array( '/key' ),
+			'empty key'                    => array( 'namespace/' ),
+			'uppercase namespace'          => array( 'MyPlugin/key' ),
+			'uppercase key'                => array( 'plugin/MyKey' ),
+			'leading hyphen in namespace'  => array( '-plugin/key' ),
+			'trailing hyphen in namespace' => array( 'plugin-/key' ),
+			'leading underscore in key'    => array( 'plugin/_key' ),
+			'trailing underscore in key'   => array( 'plugin/key_' ),
+			'space in key'                 => array( 'plugin/key with space' ),
+			'dot in key'                   => array( 'plugin/key.name' ),
+			'non-ascii'                    => array( 'plügin/key' ),
+			'one character over the max'   => array( str_repeat( 'a', 85 ) . '/' . str_repeat( 'b', 87 ) ),
+		);
+	}
+}
Index: /trunk/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php
===================================================================
--- /trunk/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php	(revision 64337)
+++ /trunk/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php	(revision 64337)
@@ -0,0 +1,234 @@
+<?php
+/**
+ * Tests for wp_set_secret() and wp_get_secret().
+ *
+ * @group secrets
+ */
+class Tests_Secrets_WpSetSecretAndWpGetSecret extends WP_UnitTestCase {
+
+	use WP_Secrets_Assertions;
+
+	public function test_set_then_get_round_trips(): void {
+		$this->assertTrue( wp_set_secret( 'myplugin/api-key', 'sk_live_secret' ) );
+
+		$secret = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+		$this->assertSame( 'sk_live_secret', $secret->reveal() );
+		$this->assertSame( 'myplugin/api-key', $secret->get_name() );
+		$this->assertNotSame( '', $secret->fingerprint() );
+	}
+
+	public function test_get_returns_null_for_an_absent_secret(): void {
+		$this->assertNull( wp_get_secret( 'myplugin/never-set' ) );
+	}
+
+	public function test_get_defaults_to_the_current_version(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$default = wp_get_secret( 'myplugin/api-key' );
+		$current = wp_get_secret( 'myplugin/api-key', WP_Secret_Version::CURRENT );
+
+		$this->assertInstanceOf( WP_Secret::class, $default );
+		$this->assertInstanceOf( WP_Secret::class, $current );
+		$this->assertSame( $default->reveal(), $current->reveal() );
+	}
+
+	public function test_get_previous_on_a_never_rotated_secret_is_null_not_error(): void {
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$this->assertNull( wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ) );
+	}
+
+	/**
+	 * An unrecognised version is a caller mistake, and WordPress reports those as
+	 * WP_Error plus a _doing_it_wrong() notice rather than by throwing. The
+	 * WP_Error branch of the three-state contract already covers it, so this adds
+	 * no fourth state.
+	 */
+	public function test_an_invalid_version_is_a_wp_error_not_an_exception(): void {
+		$this->setExpectedIncorrectUsage( '_wp_secrets_get' );
+
+		$result = wp_get_secret( 'myplugin/api-key', 'not-a-real-version' ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.)
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() );
+	}
+
+	public function test_set_rejects_an_invalid_name(): void {
+		$result = wp_set_secret( 'Not A Valid Name', 'value' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() );
+	}
+
+	public function test_get_rejects_an_invalid_name(): void {
+		$result = wp_get_secret( 'Not A Valid Name' );
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() );
+	}
+
+	public function test_set_rejects_a_non_string_value(): void {
+		$result = wp_set_secret( 'myplugin/api-key', array( 'not', 'a', 'string' ) ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.)
+
+		$this->assertWPError( $result );
+		$this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() );
+	}
+
+	public function test_set_accepts_an_empty_string_value(): void {
+		$this->assertTrue( wp_set_secret( 'myplugin/api-key', '' ) );
+
+		$secret = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+		$this->assertSame( '', $secret->reveal() );
+	}
+
+	/**
+	 * Overwriting is the only repair path an operator has through this API, so a
+	 * corrupted existing record must not block a write.
+	 */
+	public function test_a_corrupt_record_can_be_overwritten(): void {
+		update_option( '_wp_secret_myplugin/corrupt', 'not a record at all', false );
+
+		$this->assertTrue( wp_set_secret( 'myplugin/corrupt', 'a fresh value' ) );
+
+		$secret = wp_get_secret( 'myplugin/corrupt' );
+
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+		$this->assertSame( 'a fresh value', $secret->reveal() );
+	}
+
+	/**
+	 * Nothing plaintext reaches the database. Scans every option row rather than just
+	 * the secret's own, since the failure this guards against is a plaintext landing
+	 * somewhere unexpected.
+	 */
+	public function test_the_options_table_contains_no_plaintext_after_a_write(): void {
+		global $wpdb;
+
+		$this->assertInstanceOf( wpdb::class, $wpdb );
+
+		wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' );
+
+		$this->assertNeverContainsPlaintext(
+			'UNIQUE-PLAINTEXT-CANARY-9f3a',
+			$wpdb->get_results( "SELECT option_name, option_value FROM {$wpdb->options}", ARRAY_A ),
+			'The options table must never contain a plaintext secret.'
+		);
+	}
+
+	/**
+	 * Zeroing the internal plaintext must not reach the vault's copy.
+	 *
+	 * The retrieval path clears its own local copy once the WP_Secret owns the value;
+	 * that only leaves the vault intact because PHP's copy-on-write splits the two
+	 * references first.
+	 */
+	public function test_reveal_still_works_after_the_internal_memzero(): void {
+		wp_set_secret( 'myplugin/api-key', 'must-survive-memzero' );
+
+		$secret = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $secret );
+		$this->assertSame( 'must-survive-memzero', $secret->reveal() );
+	}
+
+	public function test_no_capability_check_is_applied(): void {
+		wp_set_current_user( 0 ); // No logged-in user, no capabilities at all.
+
+		$this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) );
+		$this->assertInstanceOf( WP_Secret::class, wp_get_secret( 'myplugin/api-key' ) );
+	}
+
+	public function test_change_hook_fires_on_create_with_created_action(): void {
+		$captured = null;
+		add_action(
+			'wp_secret_changed',
+			function ( ...$args ) use ( &$captured ) {
+				$captured = $args;
+			},
+			10,
+			7
+		);
+
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$this->assertNotNull( $captured );
+		list( $name, $action, $actor_id, $timestamp, $old_fingerprint, $new_fingerprint, $network ) = $captured;
+
+		$this->assertSame( 'myplugin/api-key', $name );
+		$this->assertSame( 'created', $action );
+		$this->assertSame( '', $old_fingerprint );
+		$this->assertNotSame( '', $new_fingerprint );
+		$this->assertFalse( $network );
+	}
+
+	public function test_change_hook_fires_on_overwrite_with_updated_action(): void {
+		wp_set_secret( 'myplugin/api-key', 'first-value' );
+		$first = wp_get_secret( 'myplugin/api-key' );
+
+		$this->assertInstanceOf( WP_Secret::class, $first );
+		$first_fingerprint = $first->fingerprint();
+
+		$captured = null;
+		add_action(
+			'wp_secret_changed',
+			function ( ...$args ) use ( &$captured ) {
+				$captured = $args;
+			},
+			10,
+			7
+		);
+
+		wp_set_secret( 'myplugin/api-key', 'second-value' );
+
+		$this->assertIsArray( $captured );
+		list( , $action, , , $old_fingerprint, $new_fingerprint ) = $captured;
+
+		$this->assertSame( 'updated', $action );
+		$this->assertSame( $first_fingerprint, $old_fingerprint );
+		$this->assertNotSame( $old_fingerprint, $new_fingerprint );
+	}
+
+	public function test_change_hook_reports_the_current_user_as_actor(): void {
+		$user_id = self::factory()->user->create();
+		wp_set_current_user( $user_id );
+
+		$captured_actor = null;
+		add_action(
+			'wp_secret_changed',
+			function ( $name, $action, $actor_id ) use ( &$captured_actor ) {
+				$captured_actor = $actor_id;
+			},
+			10,
+			3
+		);
+
+		wp_set_secret( 'myplugin/api-key', 'value' );
+
+		$this->assertSame( $user_id, $captured_actor );
+	}
+
+	public function test_change_hook_never_receives_a_value(): void {
+		$captured_args = null;
+		add_action(
+			'wp_secret_changed',
+			function ( ...$args ) use ( &$captured_args ) {
+				$captured_args = $args;
+			},
+			10,
+			7
+		);
+
+		wp_set_secret( 'myplugin/api-key', 'a-plaintext-value-that-must-not-leak' );
+
+		$this->assertIsArray( $captured_args );
+
+		foreach ( $captured_args as $arg ) {
+			$this->assertIsScalar( $arg );
+			$this->assertStringNotContainsString( 'a-plaintext-value-that-must-not-leak', (string) $arg );
+		}
+	}
+}
Index: /trunk/tests/phpunit/tests/user/capabilities.php
===================================================================
--- /trunk/tests/phpunit/tests/user/capabilities.php	(revision 64336)
+++ /trunk/tests/phpunit/tests/user/capabilities.php	(revision 64337)
@@ -127,4 +127,5 @@
 			'list_users'              => array( 'administrator' ),
 			'manage_options'          => array( 'administrator' ),
+			'manage_secrets'          => array( 'administrator' ),
 			'promote_users'           => array( 'administrator' ),
 			'remove_users'            => array( 'administrator' ),
@@ -210,4 +211,5 @@
 			'list_users'              => array( 'administrator' ),
 			'manage_options'          => array( 'administrator' ),
+			'manage_secrets'          => array( 'administrator' ),
 			'promote_users'           => array( 'administrator' ),
 			'remove_users'            => array( 'administrator' ),
@@ -276,4 +278,5 @@
 			'manage_network_themes'       => array(),
 			'manage_network_options'      => array(),
+			'manage_network_secrets'      => array(),
 			'delete_site'                 => array(),
 			'upgrade_network'             => array(),
@@ -315,4 +318,5 @@
 			'manage_network_themes'       => array(),
 			'manage_network_options'      => array(),
+			'manage_network_secrets'      => array(),
 			'setup_network'               => array(),
 			'upload_plugins'              => array(),
