Commit 42ee498895 for wordpress.org

commit 42ee49889536298c9d18cfd5bb37492d4feab894
Author: whyisjake <whyisjake@git.wordpress.org>
Date:   Tue Nov 10 16:00:02 2026 +0000

    Security: Make the Secrets API extension points abstract classes.

    `WP_Secrets_Provider`, `WP_Secrets_Store` and `WP_Secrets_Keyring` were interfaces. Once they ship, adding a method to one would be a fatal error in every `secrets.php` drop-in that implements it, so they could never change. As abstract classes, a later release can add a method with a default implementation and existing drop-ins keep working.

    The method signatures are unchanged. The three files are renamed from `interface-wp-secrets-*.php` to `class-wp-secrets-*.php`, and the default and fail-closed implementations now extend the classes.

    A drop-in written against the interfaces must change `implements` to `extends`. Until it does, loading it is a fatal error.

    Follow-up to r64337, r65375.

    Props ericmann, jorbin.
    See #66187.

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


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

diff --git a/wp-includes/load.php b/wp-includes/load.php
index 4cd2b4467c..edc4605704 100644
--- a/wp-includes/load.php
+++ b/wp-includes/load.php
@@ -828,8 +828,8 @@ function wp_using_ext_object_cache( $using = null ) {
  * 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.
+ * Runs once, before must-use plugins load, so that every class a drop-in's classes
+ * extend already exists and every plugin sees the drop-in's provider.
  *
  * The drop-in sets its overrides in the `$wp_secrets_provider`, `$wp_secrets_store`
  * and `$wp_secrets_keyring` globals. They are declared global before the require, so
@@ -841,14 +841,14 @@ function wp_using_ext_object_cache( $using = null ) {
  *
  * 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
+ * `$GLOBALS['wp_secrets_keyring']` is set to something that does not extend its
+ * class. 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
+ * PHP treats a class that extends an abstract class but omits one of its abstract
+ * methods 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
diff --git a/wp-includes/secrets/class-wp-secrets-broken-keyring.php b/wp-includes/secrets/class-wp-secrets-broken-keyring.php
index 1ad873345a..5af39029e8 100644
--- a/wp-includes/secrets/class-wp-secrets-broken-keyring.php
+++ b/wp-includes/secrets/class-wp-secrets-broken-keyring.php
@@ -20,7 +20,7 @@
  *
  * @since 7.2.0
  */
-final class WP_Secrets_Broken_Keyring implements WP_Secrets_Keyring {
+final class WP_Secrets_Broken_Keyring extends WP_Secrets_Keyring {

 	/**
 	 * Always fails closed.
diff --git a/wp-includes/secrets/class-wp-secrets-broken-provider.php b/wp-includes/secrets/class-wp-secrets-broken-provider.php
index 8edeee93b1..fe90ca4aca 100644
--- a/wp-includes/secrets/class-wp-secrets-broken-provider.php
+++ b/wp-includes/secrets/class-wp-secrets-broken-provider.php
@@ -22,7 +22,7 @@
  *
  * @since 7.2.0
  */
-final class WP_Secrets_Broken_Provider implements WP_Secrets_Provider {
+final class WP_Secrets_Broken_Provider extends WP_Secrets_Provider {

 	/**
 	 * The single error every operation reports.
diff --git a/wp-includes/secrets/class-wp-secrets-broken-store.php b/wp-includes/secrets/class-wp-secrets-broken-store.php
index bdb24327f3..affa2d7bf1 100644
--- a/wp-includes/secrets/class-wp-secrets-broken-store.php
+++ b/wp-includes/secrets/class-wp-secrets-broken-store.php
@@ -21,7 +21,7 @@
  *
  * @since 7.2.0
  */
-final class WP_Secrets_Broken_Store implements WP_Secrets_Store {
+final class WP_Secrets_Broken_Store extends WP_Secrets_Store {

 	/**
 	 * Always fails closed.
diff --git a/wp-includes/secrets/class-wp-secrets-config-key-provider.php b/wp-includes/secrets/class-wp-secrets-config-key-provider.php
index 64f253fd73..19f6a68300 100644
--- a/wp-includes/secrets/class-wp-secrets-config-key-provider.php
+++ b/wp-includes/secrets/class-wp-secrets-config-key-provider.php
@@ -38,7 +38,7 @@
  *
  * @since 7.2.0
  */
-final class WP_Secrets_Config_Key_Provider implements WP_Secrets_Keyring {
+final class WP_Secrets_Config_Key_Provider extends WP_Secrets_Keyring {

 	/**
 	 * AAD binding the root key's wrapped form to this purpose.
diff --git a/wp-includes/secrets/interface-wp-secrets-keyring.php b/wp-includes/secrets/class-wp-secrets-keyring.php
similarity index 76%
rename from wp-includes/secrets/interface-wp-secrets-keyring.php
rename to wp-includes/secrets/class-wp-secrets-keyring.php
index 044e8270be..282c79daf2 100644
--- a/wp-includes/secrets/interface-wp-secrets-keyring.php
+++ b/wp-includes/secrets/class-wp-secrets-keyring.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Secrets API: WP_Secrets_Keyring interface
+ * Secrets API: WP_Secrets_Keyring class
  *
  * @package WordPress
  * @subpackage Secrets
@@ -10,7 +10,7 @@
 /**
  * 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
+ * A KMS or HSM lives behind this class 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.
@@ -18,9 +18,13 @@
  * An implementation should run WP_Secrets_Keyring_Conformance against itself before
  * shipping.
  *
+ * Implementations extend this class. It is abstract rather than an interface so
+ * that a later release can add a method with a default implementation, and every
+ * existing subclass keeps working.
+ *
  * @since 7.2.0
  */
-interface WP_Secrets_Keyring {
+abstract class WP_Secrets_Keyring {

 	/**
 	 * Wraps (encrypts) raw key material for storage.
@@ -38,7 +42,7 @@ interface WP_Secrets_Keyring {
 	 * @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(
+	abstract public function wrap(
 		#[\SensitiveParameter]
 		string $key_material
 	);
@@ -51,7 +55,7 @@ interface WP_Secrets_Keyring {
 	 * @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 );
+	abstract public function unwrap( string $wrapped );

 	/**
 	 * A human-readable description of the key source, for Site Health.
@@ -60,5 +64,5 @@ interface WP_Secrets_Keyring {
 	 *
 	 * @return string
 	 */
-	public function get_key_source(): string;
+	abstract public function get_key_source(): string;
 }
diff --git a/wp-includes/secrets/class-wp-secrets-libsodium-provider.php b/wp-includes/secrets/class-wp-secrets-libsodium-provider.php
index 3481e28f46..521fc36d9a 100644
--- a/wp-includes/secrets/class-wp-secrets-libsodium-provider.php
+++ b/wp-includes/secrets/class-wp-secrets-libsodium-provider.php
@@ -13,7 +13,7 @@
  * 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-
+ * through WP_Secrets_Provider, the same class a platform extends, so a KMS-
  * or HSM-backed provider is a peer rather than an exception carved into the side of
  * this one.
  *
@@ -32,7 +32,7 @@
  *
  * @since 7.2.0
  */
-final class WP_Secrets_Libsodium_Provider implements WP_Secrets_Provider {
+final class WP_Secrets_Libsodium_Provider extends WP_Secrets_Provider {

 	/**
 	 * Where ciphertext records live.
diff --git a/wp-includes/secrets/class-wp-secrets-option-store.php b/wp-includes/secrets/class-wp-secrets-option-store.php
index 5519d7d69f..061deeac23 100644
--- a/wp-includes/secrets/class-wp-secrets-option-store.php
+++ b/wp-includes/secrets/class-wp-secrets-option-store.php
@@ -21,7 +21,7 @@
  *
  * @since 7.2.0
  */
-final class WP_Secrets_Option_Store implements WP_Secrets_Store {
+final class WP_Secrets_Option_Store extends WP_Secrets_Store {

 	/**
 	 * Option name prefix for site-scope secrets.
diff --git a/wp-includes/secrets/interface-wp-secrets-provider.php b/wp-includes/secrets/class-wp-secrets-provider.php
similarity index 90%
rename from wp-includes/secrets/interface-wp-secrets-provider.php
rename to wp-includes/secrets/class-wp-secrets-provider.php
index 671855afa5..167100430c 100644
--- a/wp-includes/secrets/interface-wp-secrets-provider.php
+++ b/wp-includes/secrets/class-wp-secrets-provider.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Secrets API: WP_Secrets_Provider interface
+ * Secrets API: WP_Secrets_Provider class
  *
  * @package WordPress
  * @subpackage Secrets
@@ -8,20 +8,20 @@
  */

 /**
- * Interface for a secrets provider.
+ * Base class 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.
+ * one a hosting platform extends.
  *
  * 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.
+ * service, a hardware security module, or its own control panel extends this
+ * same class.
  *
  * 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
+ * where the default would have stored ciphertext is what this class exists to
  * prevent. Receiving a value over an authenticated channel and holding it in a
  * hardware security module satisfies that requirement.
  *
@@ -49,9 +49,13 @@
  *   caching the raw value alongside it would undo that. Memoize within the request
  *   only.
  *
+ * Implementations extend this class. It is abstract rather than an interface so
+ * that a later release can add a method with a default implementation, and every
+ * existing subclass keeps working.
+ *
  * @since 7.2.0
  */
-interface WP_Secrets_Provider {
+abstract class WP_Secrets_Provider {

 	/**
 	 * Protection happens inside WordPress.
@@ -94,7 +98,7 @@ interface WP_Secrets_Provider {
 	 *
 	 * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version
 	 */
-	public function get( string $name, string $version, bool $network = false );
+	abstract public function get( string $name, string $version, bool $network = false );

 	/**
 	 * Stores a secret.
@@ -138,7 +142,7 @@ interface WP_Secrets_Provider {
 	 *                                    'updated'.
 	 * @return true|WP_Error
 	 */
-	public function set(
+	abstract public function set(
 		string $name,
 		#[\SensitiveParameter]
 		$value,
@@ -162,7 +166,7 @@ interface WP_Secrets_Provider {
 	 * @param bool   $network Whether this is a network-scope secret.
 	 * @return true|WP_Error
 	 */
-	public function delete( string $name, bool $network = false );
+	abstract public function delete( string $name, bool $network = false );

 	/**
 	 * Clears a secret's previous version, if this provider keeps one.
@@ -180,7 +184,7 @@ interface WP_Secrets_Provider {
 	 * @param bool   $network Whether this is a network-scope secret.
 	 * @return true|WP_Error
 	 */
-	public function retire_previous( string $name, bool $network = false );
+	abstract public function retire_previous( string $name, bool $network = false );

 	/**
 	 * Lists secrets by name and metadata. Never values.
@@ -195,7 +199,7 @@ interface WP_Secrets_Provider {
 	 *
 	 * @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 );
+	abstract public function list_secrets( string $name_prefix = '', bool $network = false );

 	/**
 	 * A short human-readable name for this provider, for Site Health.
@@ -208,7 +212,7 @@ interface WP_Secrets_Provider {
 	 *
 	 * @return string
 	 */
-	public function get_label(): string;
+	abstract public function get_label(): string;

 	/**
 	 * Where secrets this provider holds are actually protected.
@@ -225,7 +229,7 @@ interface WP_Secrets_Provider {
 	 *
 	 * @phpstan-return self::BOUNDARY_*
 	 */
-	public function get_protection_boundary(): string;
+	abstract public function get_protection_boundary(): string;

 	/**
 	 * Whether wp_set_secret() can succeed against this provider.
@@ -239,5 +243,5 @@ interface WP_Secrets_Provider {
 	 *
 	 * @return bool
 	 */
-	public function is_writable(): bool;
+	abstract public function is_writable(): bool;
 }
diff --git a/wp-includes/secrets/interface-wp-secrets-store.php b/wp-includes/secrets/class-wp-secrets-store.php
similarity index 78%
rename from wp-includes/secrets/interface-wp-secrets-store.php
rename to wp-includes/secrets/class-wp-secrets-store.php
index 81759a483b..4431eacc14 100644
--- a/wp-includes/secrets/interface-wp-secrets-store.php
+++ b/wp-includes/secrets/class-wp-secrets-store.php
@@ -1,6 +1,6 @@
 <?php
 /**
- * Secrets API: WP_Secrets_Store interface
+ * Secrets API: WP_Secrets_Store class
  *
  * @package WordPress
  * @subpackage Secrets
@@ -20,9 +20,13 @@
  * the write succeeded. Providers declare writability up front through
  * WP_Secrets_Provider::is_writable().
  *
+ * Implementations extend this class. It is abstract rather than an interface so
+ * that a later release can add a method with a default implementation, and every
+ * existing subclass keeps working.
+ *
  * @since 7.2.0
  */
-interface WP_Secrets_Store {
+abstract class WP_Secrets_Store {

 	/**
 	 * Reads a secret's stored record.
@@ -34,7 +38,7 @@ interface WP_Secrets_Store {
 	 * @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 );
+	abstract public function get( string $name, bool $network = false );

 	/**
 	 * Writes a secret's record.
@@ -47,7 +51,7 @@ interface WP_Secrets_Store {
 	 * @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 );
+	abstract public function set( string $name, array $record, bool $network = false );

 	/**
 	 * Deletes a secret's record.
@@ -59,7 +63,7 @@ interface WP_Secrets_Store {
 	 * @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 );
+	abstract public function delete( string $name, bool $network = false );

 	/**
 	 * Lists the names of every secret in this store, for this scope. Never a value.
@@ -69,5 +73,5 @@ interface WP_Secrets_Store {
 	 * @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 );
+	abstract public function list_names( bool $network = false );
 }
diff --git a/wp-includes/version.php b/wp-includes/version.php
index 4a5e36c4cc..28f0a12007 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.2-beta4-65389';
+$wp_version = '7.2-beta4-65390';

 /**
  * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.
diff --git a/wp-settings.php b/wp-settings.php
index 92f9456aa0..3c5bc8542e 100644
--- a/wp-settings.php
+++ b/wp-settings.php
@@ -324,9 +324,9 @@ 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-provider.php';
+require ABSPATH . WPINC . '/secrets/class-wp-secrets-keyring.php';
+require ABSPATH . WPINC . '/secrets/class-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';