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.