Commit b894bd114c for wordpress.org
commit b894bd114c315d8e1c96ba2b61b5027bbba92086
Author: peterwilsoncc <peterwilsoncc@git.wordpress.org>
Date: Thu Nov 5 03:01:00 2026 +0000
Security: Improve Secrets API tests and inline documentation.
Brings the Secrets API tests in line with the PHPUnit test-writing guidelines and tidies the inline documentation.
Documentation:
* Remove references to the feature plugin, its proposal and its decision records, including in the `_doing_it_wrong()` notice for unnamespaced names.
* Add `@global` annotations for the drop-in globals, rewrite the Site Health test summaries as statements, and correct the `WP_Secret::__debugInfo()` description, which also applies to `print_r()`.
* Use `/* */` for multi-line comments, "ID" rather than "id", and `@subpackage Secrets` in the test files.
Tests:
* Add `@ticket 66187` to each test class, a summary and `@covers` annotation to each test, and order annotations consistently.
* Move `@covers` targets in the conformance suites from the interfaces to the implementations under test, declared on the concrete test classes, so coverage is attributed to the code that runs. The base suites no longer filter coverage for extending classes.
* Add a unique failure message to every assertion within each test, including a new `$message` parameter for `assertRecordSlotDecryptsTo()`.
* Assert the result of every setup write. This exposed two separate-process tests that passed without testing anything because no usable key was available; both now define a test key and check the specific error returned.
* Remove duplicated tests, rename tests whose names did not match what they check, and name single-use data providers after their test.
* Add tests for a store that cannot list its secrets, for every `WP_SECRETS_ERROR_*` constant being pinned, and for a valid but different `WP_SECRETS_KEY`.
* Strengthen assertions that could pass without checking their stated behavior, such as missing error codes, counts and the salt-derived key.
* Use the `ms-required` and `multisite` groups with `skipWithoutMultisite()` for multisite-only tests, and `skipWithMultisite()` for the `ms-excluded` test, instead of skipping with `markTestSkipped()`.
Developed in https://github.com/WordPress/wordpress-develop/pull/15481.
Follow-up to r64337, r64344, r64345, r64674.
See #66187, #66353.
Built from https://develop.svn.wordpress.org/trunk@65272
git-svn-id: http://core.svn.wordpress.org/trunk@64421 1a063a9b-81f0-0310-95a4-ce76da25c4cd
diff --git a/wp-admin/includes/class-wp-site-health.php b/wp-admin/includes/class-wp-site-health.php
index b9ce4f4728..6b7b8907d5 100644
--- a/wp-admin/includes/class-wp-site-health.php
+++ b/wp-admin/includes/class-wp-site-health.php
@@ -7,7 +7,6 @@
* @since 5.2.0
*/
-
require_once __DIR__ . '/secrets-site-health.php';
#[AllowDynamicProperties]
diff --git a/wp-admin/includes/secrets-site-health.php b/wp-admin/includes/secrets-site-health.php
index d6a19b4920..646bc4a918 100644
--- a/wp-admin/includes/secrets-site-health.php
+++ b/wp-admin/includes/secrets-site-health.php
@@ -10,9 +10,8 @@
/**
* 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.
+ * There is no settings screen for secrets, so these tests are how an operator
+ * notices a broken key, a weak key source, or a pending rotation.
*
* @since 7.2.0
*
@@ -74,7 +73,7 @@ function wp_secrets_site_health_result( string $test, string $label, string $sta
}
/**
- * Site Health test: is the site key a dedicated constant, or a weaker fallback?
+ * Tests whether the site key comes from a dedicated constant or a weaker fallback.
*
* @since 7.2.0
*
@@ -130,7 +129,7 @@ function wp_secrets_site_health_test_key_source(): array {
}
/**
- * Site Health test: does every stored secret still decrypt?
+ * Tests whether every stored secret still decrypts.
*
* Network secrets are included only for a super admin on a multisite install –
* never shown to a site administrator who is not one.
@@ -218,7 +217,7 @@ function wp_secrets_site_health_find_undecryptable( bool $network ): array {
}
/**
- * Site Health test: are any secrets flagged as needing rotation?
+ * Tests whether any secrets are flagged as needing rotation.
*
* @since 7.2.0
*
diff --git a/wp-includes/load.php b/wp-includes/load.php
index ecd4220e5a..31f82524ba 100644
--- a/wp-includes/load.php
+++ b/wp-includes/load.php
@@ -849,6 +849,12 @@ function wp_using_ext_object_cache( $using = null ) {
*
* @since 7.2.0
* @access private
+ *
+ * @global bool $wp_secrets_dropin_loaded Whether a secrets.php drop-in file was found.
+ * @global bool $wp_secrets_dropin_broken Whether the drop-in failed to load or left an invalid override.
+ * @global WP_Secrets_Provider $wp_secrets_provider Optional. Provider override set by the drop-in.
+ * @global WP_Secrets_Store $wp_secrets_store Optional. Store override set by the drop-in.
+ * @global WP_Secrets_Keyring $wp_secrets_keyring Optional. Keyring override set by the drop-in.
*/
function wp_load_secrets_dropin(): void {
static $loaded = false;
diff --git a/wp-includes/secrets.php b/wp-includes/secrets.php
index 99d87ed0d7..826eb41b88 100644
--- a/wp-includes/secrets.php
+++ b/wp-includes/secrets.php
@@ -50,7 +50,7 @@ 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.
+ * 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
@@ -86,9 +86,9 @@ define( 'WP_SECRETS_ERROR_RECORD_MALFORMED', 'secret_record_malformed' );
*
* 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.
+ * WordPress than is currently running. The upgrade path for a future record format
+ * is not designed, so an unrecognized version is rejected outright rather than
+ * guessed at.
*
* @since 7.2.0
*/
@@ -184,15 +184,19 @@ function wp_secrets_memzero( &$value ): void {
$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.
+ /*
+ * 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.
+ /*
+ * Reached on every path, including sodium_memzero() success (it leaves the
+ * variable null, not an empty string) so callers see one consistent contract.
+ */
$value = '';
}
@@ -208,9 +212,9 @@ function wp_secrets_memzero( &$value ): void {
* 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.
+ * _doing_it_wrong(). It exists for one reason: existing code may already use 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.
@@ -268,7 +272,7 @@ function wp_secrets_validate_name( string $name ) {
__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.' ),
+ __( '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 existing code using flat names can be ported incrementally.' ),
$name
),
'7.2.0'
@@ -301,10 +305,10 @@ function wp_secrets_validate_name( string $name ) {
/**
* 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.
+ * No filter is applied here, or anywhere on the retrieval path. 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
@@ -314,6 +318,9 @@ function wp_secrets_validate_name( string $name ) {
*
* @since 7.2.0
*
+ * @global bool $wp_secrets_dropin_broken Whether the secrets.php drop-in failed to load cleanly.
+ * @global WP_Secrets_Store $wp_secrets_store Optional. Store override set by the drop-in.
+ *
* @return WP_Secrets_Store
*/
function _wp_secrets_get_store(): WP_Secrets_Store {
@@ -350,6 +357,9 @@ function _wp_secrets_get_store(): WP_Secrets_Store {
*
* @since 7.2.0
*
+ * @global bool $wp_secrets_dropin_broken Whether the secrets.php drop-in failed to load cleanly.
+ * @global WP_Secrets_Keyring $wp_secrets_keyring Optional. Keyring override set by the drop-in.
+ *
* @return WP_Secrets_Key_Manager
*/
function _wp_secrets_get_key_manager(): WP_Secrets_Key_Manager {
@@ -385,30 +395,14 @@ function _wp_secrets_get_key_manager(): WP_Secrets_Key_Manager {
*
* @since 7.2.0
*
+ * @global bool $wp_secrets_dropin_loaded Whether a secrets.php drop-in file was found.
+ *
* @return bool
*/
function wp_using_secrets_dropin(): bool {
return ! empty( $GLOBALS['wp_secrets_dropin_loaded'] );
}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
/**
* Encrypts and stores a secret.
*
@@ -430,10 +424,9 @@ function wp_set_secret( string $name, $value ) {
/**
* 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.
+ * For an explicit migration on the caller's own schedule, 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
@@ -474,8 +467,6 @@ function wp_import_option_as_secret( string $option, string $name ) {
return _wp_secrets_set( $name, $value, false, true, 'imported' );
}
-
-
/**
* Retrieves a secret.
*
@@ -514,8 +505,6 @@ function wp_delete_secret( string $name ) {
* 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.
@@ -528,10 +517,6 @@ function wp_retire_secret_version( string $name ) {
/**
* 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
@@ -542,7 +527,7 @@ function wp_retire_secret_version( string $name ) {
*
* @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.
+function wp_list_secrets( string $namespace = '' ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.namespaceFound -- $namespace is the public parameter name; renaming it would break named-argument callers.
return _wp_secrets_list( $namespace, false );
}
@@ -551,8 +536,7 @@ function wp_list_secrets( string $namespace = '' ) { // phpcs:ignore Universal.N
*
* 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.
+ * scope to the other.
*
* @since 7.2.0
*
@@ -641,6 +625,9 @@ function wp_list_network_secrets( string $namespace = '' ) { // phpcs:ignore Uni
*
* @since 7.2.0
*
+ * @global bool $wp_secrets_dropin_broken Whether the secrets.php drop-in failed to load cleanly.
+ * @global WP_Secrets_Provider $wp_secrets_provider Optional. Provider override set by the drop-in.
+ *
* @return WP_Secrets_Provider
*/
function _wp_secrets_get_provider(): WP_Secrets_Provider {
diff --git a/wp-includes/secrets/class-wp-secret.php b/wp-includes/secrets/class-wp-secret.php
index 260990c3a6..d8db880ef3 100644
--- a/wp-includes/secrets/class-wp-secret.php
+++ b/wp-includes/secrets/class-wp-secret.php
@@ -31,7 +31,7 @@ final class WP_Secret implements JsonSerializable {
* 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
+ * 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.
*
@@ -224,13 +224,12 @@ final class WP_Secret implements JsonSerializable {
}
/**
- * Masks the instance for var_dump().
+ * Masks the instance for var_dump() and print_r().
*
- * 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.
+ * Note that var_export() does not use this hook. It does not expose plaintext
+ * from the vault, but it enumerates all instance properties, including the
+ * unmasked WP_Error reason on a withheld secret. Use a masked output path
+ * instead when the reason must remain private.
*
* @since 7.2.0
*
@@ -314,7 +313,7 @@ final class WP_Secret implements JsonSerializable {
* 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
+ * 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.
*
diff --git a/wp-includes/secrets/class-wp-secrets-broken-store.php b/wp-includes/secrets/class-wp-secrets-broken-store.php
index 31e64d8e5c..bdb24327f3 100644
--- a/wp-includes/secrets/class-wp-secrets-broken-store.php
+++ b/wp-includes/secrets/class-wp-secrets-broken-store.php
@@ -75,7 +75,6 @@ final class WP_Secrets_Broken_Store implements WP_Secrets_Store {
return $this->error();
}
-
/**
* Builds the error every method returns.
*
diff --git a/wp-includes/secrets/class-wp-secrets-cipher.php b/wp-includes/secrets/class-wp-secrets-cipher.php
index 4dc631597c..5cb12848d3 100644
--- a/wp-includes/secrets/class-wp-secrets-cipher.php
+++ b/wp-includes/secrets/class-wp-secrets-cipher.php
@@ -256,7 +256,7 @@ final class WP_Secrets_Cipher {
*
* @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 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
@@ -284,7 +284,7 @@ final class WP_Secrets_Cipher {
*
* @param string $master_key Candidate master key.
* @param string $scope Candidate scope.
- * @param int $site_id Candidate site id.
+ * @param int $site_id Candidate site ID.
* @param string $name Candidate secret name.
* @param string $slot Candidate slot.
* @return true|WP_Error
diff --git a/wp-includes/secrets/class-wp-secrets-key-manager.php b/wp-includes/secrets/class-wp-secrets-key-manager.php
index 91740370d8..de2e7302a4 100644
--- a/wp-includes/secrets/class-wp-secrets-key-manager.php
+++ b/wp-includes/secrets/class-wp-secrets-key-manager.php
@@ -22,7 +22,7 @@
* - 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
+ * 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.
*
@@ -66,9 +66,9 @@ final class WP_Secrets_Key_Manager {
const NETWORK_KDF_CONTEXT = 'wpsecnet';
/**
- * Reserved KDF subkey id for network scope.
+ * Reserved KDF subkey ID for network scope.
*
- * Blog ids start at 1, so this can never collide with a site-scope subkey id.
+ * Blog IDs start at 1, so this can never collide with a site-scope subkey ID.
*
* @since 7.2.0
* @var int
@@ -136,11 +136,11 @@ final class WP_Secrets_Key_Manager {
* @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.
+ * @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()
+ * site ID – see WP_Secrets_Cipher::validate_common()
* for why that is a WP_Error and not an exception.
*
* @phpstan-param 'site'|'network' $scope
diff --git a/wp-includes/secrets/class-wp-secrets-libsodium-provider.php b/wp-includes/secrets/class-wp-secrets-libsodium-provider.php
index 0c21195c64..7fdf1e5374 100644
--- a/wp-includes/secrets/class-wp-secrets-libsodium-provider.php
+++ b/wp-includes/secrets/class-wp-secrets-libsodium-provider.php
@@ -233,7 +233,7 @@ final class WP_Secrets_Libsodium_Provider implements WP_Secrets_Provider {
* @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 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
@@ -419,15 +419,15 @@ final class WP_Secrets_Libsodium_Provider implements WP_Secrets_Provider {
/**
* 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.
+ * This hook does not fire on the retrieval path. Fingerprints are passed;
+ * values never are.
*
* @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 $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.
@@ -485,8 +485,10 @@ final class WP_Secrets_Libsodium_Provider implements WP_Secrets_Provider {
}
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.
+ /*
+ * 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;
}
@@ -587,10 +589,6 @@ final class WP_Secrets_Libsodium_Provider implements WP_Secrets_Provider {
*
* 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.
@@ -651,10 +649,6 @@ final class WP_Secrets_Libsodium_Provider implements WP_Secrets_Provider {
/**
* 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
diff --git a/wp-includes/secrets/class-wp-secrets-option-store.php b/wp-includes/secrets/class-wp-secrets-option-store.php
index 7ffc149652..5519d7d69f 100644
--- a/wp-includes/secrets/class-wp-secrets-option-store.php
+++ b/wp-includes/secrets/class-wp-secrets-option-store.php
@@ -124,8 +124,10 @@ final class WP_Secrets_Option_Store implements WP_Secrets_Store {
return true;
}
- // delete_*_option() also returns false when the option never existed.
- // Deleting something already absent is not a failure.
+ /*
+ * 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 ) {
@@ -191,7 +193,6 @@ final class WP_Secrets_Option_Store implements WP_Secrets_Store {
return $names;
}
-
/**
* Builds the option name a secret is stored under.
*
diff --git a/wp-includes/version.php b/wp-includes/version.php
index b992ffc3dd..b03f214a0c 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
-$wp_version = '7.2-beta4-65271';
+$wp_version = '7.2-beta4-65272';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.