Commit 4ee9bdf568 for wordpress.org

commit 4ee9bdf568c4ff9ee8019d9b6aae7a805956c247
Author: whyisjake <whyisjake@git.wordpress.org>
Date:   Wed Oct 21 17:57:58 2026 +0000

    Security: Keep the previous secret version when a value is saved unchanged.

    Saving a secret's current value again demoted it to the previous slot. Settings forms do this on every submit, so a rotation in progress lost the previous value it depends on.

    `WP_Secrets_Libsodium_Provider::set()` now treats an unchanged write as a no-op: nothing is stored and `wp_secret_changed` does not fire. The exception is a write that raises `needs_rotation`, such as `wp_import_option_as_secret()` on an option holding the current value. That write is recorded and keeps the previous version.

    `WP_Secrets_Provider::set()` now documents that an unchanged write must not discard the previous version, and `WP_Secrets_Provider_Conformance` tests it, so the rule applies to third-party providers too.

    Follow-up to [r64337].

    Props ravipatel, ericmann, whyisjake.
    Fixes #66353. See #66187.

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


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

diff --git a/wp-includes/secrets/class-wp-secrets-libsodium-provider.php b/wp-includes/secrets/class-wp-secrets-libsodium-provider.php
index 6278a5dddf..dc360a1348 100644
--- a/wp-includes/secrets/class-wp-secrets-libsodium-provider.php
+++ b/wp-includes/secrets/class-wp-secrets-libsodium-provider.php
@@ -320,7 +320,36 @@ final class WP_Secrets_Libsodium_Provider implements WP_Secrets_Provider {
 			return $master_key;
 		}

-		$cipher   = new WP_Secrets_Cipher();
+		$cipher       = new WP_Secrets_Cipher();
+		$has_current  = is_array( $existing ) && isset( $existing['current'] ) && is_array( $existing['current'] );
+		$is_unchanged = false;
+
+		if ( $has_current ) {
+			$current_value = $cipher->decrypt_value( $master_key, $scope, $site_id, $name, WP_Secret_Version::CURRENT, $existing['current'] );
+			$is_unchanged  = is_string( $current_value ) && hash_equals( $current_value, $value );
+
+			if ( is_string( $current_value ) ) {
+				wp_secrets_memzero( $current_value );
+			}
+		}
+
+		/*
+		 * A write that would change nothing stored is skipped, and reports nothing
+		 * to wp_secret_changed: a settings form that saves an unchanged value on
+		 * every submit has not changed the secret. Rewriting the record would also
+		 * reset the current slot's 'created' time and clear a 'needs_rotation' flag
+		 * on a value that was never rotated.
+		 *
+		 * The exception is a write that raises 'needs_rotation', such as importing
+		 * an option that holds the value already stored. That value has still sat
+		 * in plaintext, so the flag is recorded, and handled as a rewrite below.
+		 */
+		if ( $is_unchanged && ( ! $needs_rotation || ! empty( $existing['current']['needs_rotation'] ) ) ) {
+			wp_secrets_memzero( $master_key );
+
+			return true;
+		}
+
 		$new_slot = $cipher->encrypt_value( $master_key, $scope, $site_id, $name, WP_Secret_Version::CURRENT, $value );

 		if ( is_wp_error( $new_slot ) ) {
@@ -355,12 +384,24 @@ final class WP_Secrets_Libsodium_Provider implements WP_Secrets_Provider {
 		 * 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.
+		 *
+		 * Saving the current value again, as a settings form does on every save, is
+		 * not a rotation: demoting it would replace the previous value that a
+		 * rotation in progress still depends on. The existing previous slot is kept
+		 * instead, and can be carried over as stored because it is already bound to
+		 * WP_Secret_Version::PREVIOUS.
 		 */
-		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;
+		if ( $has_current ) {
+			if ( $is_unchanged ) {
+				if ( isset( $existing['previous'] ) && is_array( $existing['previous'] ) ) {
+					$record['previous'] = $existing['previous'];
+				}
+			} else {
+				$demoted = $this->demote_slot( $cipher, $master_key, $scope, $site_id, $name, $existing['current'] );
+
+				if ( ! is_wp_error( $demoted ) ) {
+					$record['previous'] = $demoted;
+				}
 			}
 		}

diff --git a/wp-includes/secrets/interface-wp-secrets-provider.php b/wp-includes/secrets/interface-wp-secrets-provider.php
index bd11a88c71..d1916aa723 100644
--- a/wp-includes/secrets/interface-wp-secrets-provider.php
+++ b/wp-includes/secrets/interface-wp-secrets-provider.php
@@ -112,6 +112,15 @@ interface WP_Secrets_Provider {
 	 * the situation it matters most, so WP_Secrets_Provider_Conformance checks it.
 	 * A write that fails fires nothing.
 	 *
+	 * Writing the value a secret already holds is not a rotation. A settings form
+	 * that saves every field on every submit does this routinely, so a provider
+	 * that keeps a previous version must not discard it for an unchanged write;
+	 * doing so would end a rotation's grace period early, with every consumer
+	 * still on the old credential losing access. WP_Secrets_Provider_Conformance
+	 * checks this. Treating the write as a no-op that fires nothing is preferred,
+	 * but a provider that cannot tell the value is unchanged without an additional
+	 * read may write it and report it.
+	 *
 	 * @since 7.2.0
 	 *
 	 * @param string      $name           The secret's name.
diff --git a/wp-includes/version.php b/wp-includes/version.php
index a3f4c75d2c..a0ae83625f 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.2-beta1-64673';
+$wp_version = '7.2-beta1-64674';

 /**
  * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.