From fefea0eb59d68c6584d4975c5822d2aa4ff7438b Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 25 Sep 2026 12:01:33 -0700 Subject: [PATCH 01/12] Secrets API: add the API from the feature plugin Copies src/ from the Secrets API feature plugin at v0.2.0 (https://github.com/ericmann/secrets-api), unchanged: the public functions, WP_Secret and WP_Secret_Version, the provider, store, and keyring interfaces with their shipped and fail-closed implementations, the cipher and key manager, and the Site Health tests. Nothing loads these files yet; the next commit wires them in. --- src/wp-admin/includes/secrets-site-health.php | 360 ++++++++ src/wp-includes/class-wp-secret-version.php | 35 + src/wp-includes/class-wp-secret.php | 341 ++++++++ .../class-wp-secrets-broken-keyring.php | 75 ++ .../class-wp-secrets-broken-provider.php | 150 ++++ .../class-wp-secrets-broken-store.php | 96 ++ src/wp-includes/class-wp-secrets-cipher.php | 404 +++++++++ .../class-wp-secrets-config-key-provider.php | 298 +++++++ .../class-wp-secrets-key-manager.php | 381 ++++++++ .../class-wp-secrets-libsodium-provider.php | 668 ++++++++++++++ .../class-wp-secrets-option-store.php | 213 +++++ .../interface-wp-secrets-keyring.php | 60 ++ .../interface-wp-secrets-provider.php | 216 +++++ .../interface-wp-secrets-store.php | 77 ++ src/wp-includes/secrets.php | 825 ++++++++++++++++++ 15 files changed, 4199 insertions(+) create mode 100644 src/wp-admin/includes/secrets-site-health.php create mode 100644 src/wp-includes/class-wp-secret-version.php create mode 100644 src/wp-includes/class-wp-secret.php create mode 100644 src/wp-includes/class-wp-secrets-broken-keyring.php create mode 100644 src/wp-includes/class-wp-secrets-broken-provider.php create mode 100644 src/wp-includes/class-wp-secrets-broken-store.php create mode 100644 src/wp-includes/class-wp-secrets-cipher.php create mode 100644 src/wp-includes/class-wp-secrets-config-key-provider.php create mode 100644 src/wp-includes/class-wp-secrets-key-manager.php create mode 100644 src/wp-includes/class-wp-secrets-libsodium-provider.php create mode 100644 src/wp-includes/class-wp-secrets-option-store.php create mode 100644 src/wp-includes/interface-wp-secrets-keyring.php create mode 100644 src/wp-includes/interface-wp-secrets-provider.php create mode 100644 src/wp-includes/interface-wp-secrets-store.php create mode 100644 src/wp-includes/secrets.php diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php new file mode 100644 index 0000000000000..d4883032dbde1 --- /dev/null +++ b/src/wp-admin/includes/secrets-site-health.php @@ -0,0 +1,360 @@ + __( 'Secrets API key source', 'default' ), + 'test' => 'wp_secrets_site_health_test_key_source', + ); + + $tests['direct']['secrets_api_undecryptable'] = array( + 'label' => __( 'Secrets API: undecryptable secrets', 'default' ), + 'test' => 'wp_secrets_site_health_test_undecryptable', + ); + + $tests['direct']['secrets_api_needs_rotation'] = array( + 'label' => __( 'Secrets API: credentials pending rotation', 'default' ), + '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

elements. + * + * @return array + */ +function wp_secrets_site_health_result( $test, $label, $status, $description ) { + $colors = array( + 'good' => 'green', + 'recommended' => 'orange', + 'critical' => 'red', + ); + + return array( + 'test' => $test, + 'label' => $label, + 'status' => $status, + 'badge' => array( + 'label' => __( 'Security', 'default' ), + '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 + */ +function wp_secrets_site_health_test_key_source() { + $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', 'default' ), + 'critical', + sprintf( + '

%s

', + esc_html__( 'The active keyring did not load correctly. No secret can be encrypted or decrypted until this is fixed.', 'default' ) + ) + ); + } + + if ( false !== strpos( $key_source, 'legacy interpretation' ) ) { + return wp_secrets_site_health_result( + 'secrets_api_key_source', + __( 'Secrets API key should be regenerated', 'default' ), + 'recommended', + sprintf( + '

%s

', + esc_html__( 'WP_SECRETS_KEY is defined, but not in the recommended base64-encoded 32-byte form. Regenerate it with `wp secret generate-key`.', 'default' ) + ) + ); + } + + 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', 'default' ), + 'recommended', + sprintf( + '

%s

', + 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`.', 'default' ) + ) + ); + } + + return wp_secrets_site_health_result( + 'secrets_api_key_source', + __( 'Secrets API is using a dedicated key', 'default' ), + 'good', + sprintf( '

%s

', 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 + */ +function wp_secrets_site_health_test_undecryptable() { + $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', 'default' ), + 'good', + sprintf( '

%s

', esc_html__( 'Every stored secret decrypted successfully.', 'default' ) ) + ); + } + + $items = ''; + + foreach ( $broken as $entry ) { + $items .= sprintf( + '
  • %s (%s)
  • ', + esc_html( $entry['name'] ), + sprintf( + /* translators: %s: A secret's last known fingerprint. */ + esc_html__( 'fingerprint: %s', 'default' ), + esc_html( $entry['fingerprint'] ) + ) + ); + } + + return wp_secrets_site_health_result( + 'secrets_api_undecryptable', + __( 'Some secrets cannot be decrypted', 'default' ), + 'critical', + sprintf( + '

    %s

    %s

    ', + 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.', 'default' ), + $items, + esc_html__( 'This can happen after losing WP_SECRETS_KEY, restoring a database backup without its matching key, or a failed key rotation.', 'default' ) + ) + ); +} + +/** + * 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' => ... ). + */ +function wp_secrets_site_health_find_undecryptable( $network ) { + $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 + */ +function wp_secrets_site_health_test_needs_rotation() { + $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', 'default' ), + 'good', + sprintf( '

    %s

    ', esc_html__( 'No secret is flagged as needing rotation.', 'default' ) ) + ); + } + + return wp_secrets_site_health_result( + 'secrets_api_needs_rotation', + __( 'Some secrets are pending rotation', 'default' ), + 'recommended', + sprintf( + '

    %s

    ', + 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, + 'default' + ), + $count + ) + ) + ) + ); +} + +/** + * Counts secrets flagged needs_rotation, for one scope. + * + * @since 7.2.0 + * + * @param bool $network Whether to check network-scope secrets. + * + * @return int + */ +function wp_secrets_site_health_count_needing_rotation( $network ) { + $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 $info Existing debug information sections. + * + * @return array + */ +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', 'default' ), + 'value' => wp_using_secrets_dropin() ? __( 'Yes', 'default' ) : __( 'No', 'default' ), + ), + 'provider_class' => array( + 'label' => __( 'Provider class', 'default' ), + 'value' => get_class( $provider ), + ), + 'protected_by' => array( + 'label' => __( 'Secrets protected by', 'default' ), + 'value' => $provider->get_label(), + ), + 'protection_at' => array( + 'label' => __( 'Encryption boundary', 'default' ), + 'value' => WP_Secrets_Provider::BOUNDARY_WORDPRESS === $provider->get_protection_boundary() + ? __( 'WordPress', 'default' ) + : __( 'The provider (outside WordPress)', 'default' ), + ), + 'writable' => array( + 'label' => __( 'Accepts writes', 'default' ), + 'value' => $provider->is_writable() ? __( 'Yes', 'default' ) : __( 'No', 'default' ), + ), + 'keyring_class' => array( + 'label' => __( 'Keyring class', 'default' ), + 'value' => get_class( $key_manager->get_keyring() ), + ), + 'key_source' => array( + 'label' => __( 'Key source', 'default' ), + 'value' => $key_manager->get_keyring()->get_key_source(), + ), + 'record_version' => array( + 'label' => __( 'Record format version', 'default' ), + 'value' => (string) WP_SECRETS_RECORD_VERSION, + ), + ); + + $site_secrets = wp_list_secrets(); + $fields['site_secret_count'] = array( + 'label' => __( 'Site secrets', 'default' ), + 'value' => is_wp_error( $site_secrets ) ? __( 'Unavailable', 'default' ) : (string) count( $site_secrets ), + ); + + if ( is_multisite() && is_super_admin() ) { + $network_secrets = wp_list_network_secrets(); + $fields['network_secret_count'] = array( + 'label' => __( 'Network secrets', 'default' ), + 'value' => is_wp_error( $network_secrets ) ? __( 'Unavailable', 'default' ) : (string) count( $network_secrets ), + ); + } + + $info['secrets-api'] = array( + 'label' => __( 'Secrets API', 'default' ), + 'fields' => $fields, + ); + + return $info; +} +add_filter( 'debug_information', 'wp_secrets_site_health_debug_info' ); diff --git a/src/wp-includes/class-wp-secret-version.php b/src/wp-includes/class-wp-secret-version.php new file mode 100644 index 0000000000000..fbbeca759a912 --- /dev/null +++ b/src/wp-includes/class-wp-secret-version.php @@ -0,0 +1,35 @@ + + */ + 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 any argument is not a non-empty string + * (value may be empty, but must be a string). + */ + public function __construct( $name, $value, $fingerprint ) { + if ( ! is_string( $name ) || '' === $name ) { + throw new InvalidArgumentException( 'WP_Secret requires a non-empty string name.' ); + } + + if ( ! is_string( $value ) ) { + throw new InvalidArgumentException( 'WP_Secret values must be strings.' ); + } + + if ( ! is_string( $fingerprint ) || '' === $fingerprint ) { + throw new InvalidArgumentException( 'WP_Secret requires a non-empty string 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 $reason is not a WP_Error, or if $name or + * $fingerprint fail the constructor's checks. + * + * @return WP_Secret + */ + public static function withheld( $name, $fingerprint, $reason ) { + if ( ! $reason instanceof WP_Error ) { + throw new InvalidArgumentException( 'A withheld WP_Secret requires a WP_Error reason.' ); + } + + $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() { + return $this->fingerprint; + } + + /** + * Returns the secret's namespaced name. + * + * @since 7.2.0 + * + * @return string + */ + public function get_name() { + return $this->name; + } + + /** + * Builds the masked placeholder used everywhere a plaintext would otherwise appear. + * + * @since 7.2.0 + * + * @return string + */ + private function mask() { + 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 + */ + public function __toString() { + 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 + */ + public function __debugInfo(): array { + return array( 'value' => $this->mask() ); + } + + /** + * Masks the instance for json_encode(). + * + * @since 7.2.0 + * + * @return string + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() { + 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 $data Ignored. + * + * @throws LogicException Always. + * + * @return void + */ + public function __unserialize( $data ) { + 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 ] ); + } + } +} diff --git a/src/wp-includes/class-wp-secrets-broken-keyring.php b/src/wp-includes/class-wp-secrets-broken-keyring.php new file mode 100644 index 0000000000000..60c7b8e6cd6f7 --- /dev/null +++ b/src/wp-includes/class-wp-secrets-broken-keyring.php @@ -0,0 +1,75 @@ +error(); + } + + /** + * Always fails closed. + * + * @since 7.2.0 + * + * @param string $wrapped Ignored. + * + * @return WP_Error + */ + public function unwrap( $wrapped ) { + return $this->error(); + } + + /** + * Describes the broken state, for Site Health. + * + * @since 7.2.0 + * + * @return string + */ + public function get_key_source() { + return __( 'broken secrets.php drop-in', 'default' ); + } + + /** + * Builds the error every method returns. + * + * @since 7.2.0 + * + * @return WP_Error + */ + private function error() { + return new WP_Error( + WP_SECRETS_ERROR_KEY_UNAVAILABLE, + __( 'The secrets.php drop-in did not provide a usable keyring.', 'default' ) + ); + } +} diff --git a/src/wp-includes/class-wp-secrets-broken-provider.php b/src/wp-includes/class-wp-secrets-broken-provider.php new file mode 100644 index 0000000000000..5c2725d834934 --- /dev/null +++ b/src/wp-includes/class-wp-secrets-broken-provider.php @@ -0,0 +1,150 @@ +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( $name, $value, $network = false, $needs_rotation = false, $action = null ) { + 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( $name, $network = false ) { + 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( $name, $network = false ) { + 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( $name_prefix = '', $network = false ) { + return $this->error(); + } + + /** + * Says plainly that the drop-in is the problem. + * + * @since 7.2.0 + * + * @return string + */ + public function get_label() { + return __( 'Unavailable: the secrets.php drop-in did not load correctly', 'default' ); + } + + /** + * Reports the provider boundary. + * + * Whatever was meant to protect these secrets is not WordPress, and is not + * working. + * + * @since 7.2.0 + * + * @return string + */ + public function get_protection_boundary() { + return self::BOUNDARY_PROVIDER; + } + + /** + * Nothing is writable while the drop-in is broken. + * + * @since 7.2.0 + * + * @return bool + */ + public function is_writable() { + return false; + } +} diff --git a/src/wp-includes/class-wp-secrets-broken-store.php b/src/wp-includes/class-wp-secrets-broken-store.php new file mode 100644 index 0000000000000..cbb5842dfb803 --- /dev/null +++ b/src/wp-includes/class-wp-secrets-broken-store.php @@ -0,0 +1,96 @@ +error(); + } + + /** + * Always fails closed. + * + * @since 7.2.0 + * + * @param string $name Ignored. + * @param array $record Ignored. + * @param bool $network Ignored. + * + * @return WP_Error + */ + public function set( $name, $record, $network = false ) { + return $this->error(); + } + + /** + * Always fails closed. + * + * @since 7.2.0 + * + * @param string $name Ignored. + * @param bool $network Ignored. + * + * @return WP_Error + */ + public function delete( $name, $network = false ) { + return $this->error(); + } + + /** + * Always fails closed. + * + * @since 7.2.0 + * + * @param bool $network Ignored. + * + * @return WP_Error + */ + public function list_names( $network = false ) { + return $this->error(); + } + + + /** + * Builds the error every method returns. + * + * @since 7.2.0 + * + * @return WP_Error + */ + private function error() { + return new WP_Error( + WP_SECRETS_ERROR_STORE_UNAVAILABLE, + __( 'The secrets.php drop-in did not provide a usable store.', 'default' ) + ); + } +} diff --git a/src/wp-includes/class-wp-secrets-cipher.php b/src/wp-includes/class-wp-secrets-cipher.php new file mode 100644 index 0000000000000..1f7b3eb8c5744 --- /dev/null +++ b/src/wp-includes/class-wp-secrets-cipher.php @@ -0,0 +1,404 @@ +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.', 'default' ) + ); + } + + if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' ) ) { + return new WP_Error( + WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE, + __( 'No libsodium implementation is available.', 'default' ) + ); + } + + $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. + */ + public function decrypt_value( $master_key, $scope, $site_id, $name, $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.', 'default' ) + ); + } + + $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 ( false === $data_key ) { + return new WP_Error( + WP_SECRETS_ERROR_DECRYPTION_FAILED, + __( "The secret's data key could not be decrypted.", 'default' ) + ); + } + + $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 ( false === $plaintext ) { + return new WP_Error( + WP_SECRETS_ERROR_DECRYPTION_FAILED, + __( 'The secret value could not be decrypted.', 'default' ) + ); + } + + 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( $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.', 'default' ) + ); + } + + if ( ! is_string( $plaintext ) ) { + return new WP_Error( + WP_SECRETS_ERROR_INVALID_VALUE, + __( 'Secret values must be strings.', 'default' ) + ); + } + + 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.', 'default' ) + ); + } + + $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 + */ + private function build_aad( $purpose, $scope, $site_id, $name, $slot ) { + 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 + */ + private function validate_common( $master_key, $scope, $site_id, $name, $slot ) { + if ( ! in_array( $scope, array( 'site', 'network' ), true ) ) { + return $this->invalid_argument( __( 'The scope must be "site" or "network".', 'default' ) ); + } + + if ( ! is_int( $site_id ) || $site_id < 0 ) { + return $this->invalid_argument( __( 'The site id must be a non-negative integer.', 'default' ) ); + } + + 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.', 'default' ) ); + } + + if ( ! $this->is_valid_master_key( $master_key ) ) { + return new WP_Error( + WP_SECRETS_ERROR_KEY_UNAVAILABLE, + __( 'A 32-byte master key is required.', 'default' ) + ); + } + + $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( $message ) { + _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 mixed $master_key Candidate master key. + * + * @return bool + */ + private function is_valid_master_key( $master_key ) { + return is_string( $master_key ) && 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|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.', 'default' ) + ); + } + + $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.', 'default' ), + $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.', 'default' ), + $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.', 'default' ) + ); + } + + return $decoded; + } +} diff --git a/src/wp-includes/class-wp-secrets-config-key-provider.php b/src/wp-includes/class-wp-secrets-config-key-provider.php new file mode 100644 index 0000000000000..a76794a6d041c --- /dev/null +++ b/src/wp-includes/class-wp-secrets-config-key-provider.php @@ -0,0 +1,298 @@ +use_previous_key = (bool) $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( $key_material ) { + if ( ! is_string( $key_material ) || '' === $key_material ) { + return new WP_Error( + WP_SECRETS_ERROR_INVALID_VALUE, + __( 'Key material to wrap must be a non-empty string.', 'default' ) + ); + } + + if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' ) ) { + return new WP_Error( + WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE, + __( 'No libsodium implementation is available.', 'default' ) + ); + } + + $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( $wrapped ) { + if ( ! is_string( $wrapped ) || '' === $wrapped ) { + return new WP_Error( + WP_SECRETS_ERROR_KEY_UNAVAILABLE, + __( 'Nothing to unwrap.', 'default' ) + ); + } + + if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_decrypt' ) ) { + return new WP_Error( + WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE, + __( 'No libsodium implementation is available.', 'default' ) + ); + } + + $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.', 'default' ) + ); + } + + $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 ( false === $key_material ) { + return new WP_Error( + WP_SECRETS_ERROR_KEY_UNAVAILABLE, + __( 'The wrapped key material could not be decrypted with the configured site key.', 'default' ) + ); + } + + return $key_material; + } + + /** + * Describes the active key source, for Site Health. + * + * @since 7.2.0 + * + * @return string + */ + public function get_key_source() { + 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.', 'default' ), + $constant_name + ) + ); + } + + if ( $this->is_canonical_base64_32( $raw_constant ) ) { + return base64_decode( $raw_constant, true ); + } + + 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.', 'default' ) + ); + } + + $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).', 'default' ) + ); + } + + 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 + */ + private function are_usable_salt_values( $logged_in_key, $logged_in_salt ) { + 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 string $value Candidate value. + * + * @return bool + */ + private function is_canonical_base64_32( $value ) { + if ( ! is_string( $value ) ) { + return false; + } + + $decoded = base64_decode( $value, true ); + + if ( false === $decoded || SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES !== strlen( $decoded ) ) { + return false; + } + + return base64_encode( $decoded ) === $value; + } +} diff --git a/src/wp-includes/class-wp-secrets-key-manager.php b/src/wp-includes/class-wp-secrets-key-manager.php new file mode 100644 index 0000000000000..db65b6e5f47b4 --- /dev/null +++ b/src/wp-includes/class-wp-secrets-key-manager.php @@ -0,0 +1,381 @@ +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() { + 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. + */ + public function get_master_key( $scope, $site_id = null ) { + if ( ! in_array( $scope, array( 'site', 'network' ), true ) ) { + $message = __( 'The scope must be "site" or "network".', 'default' ); + + _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 ( ! is_int( $subkey_id ) || $subkey_id < 1 ) { + $message = __( 'The site id must be a positive integer.', 'default' ); + + _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.', 'default' ) + ); + } + + $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.', 'default' ) + ); + } + + if ( null !== $this->cached_wrapped && $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.', 'default' ) + ); + } + + if ( $old_keyring === $this->keyring && null !== $this->cached_wrapped && $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.', 'default' ) + ); + } + + $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 string|false The wrapped root key, or false if none exists anywhere. + */ + 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.', 'default' ) + ); + } + + $root_key = $this->keyring->unwrap( $existing ); + + if ( is_string( $root_key ) ) { + $this->cached_wrapped = $existing; + $this->cached_root_key = $root_key; + } + + return $root_key; + } +} diff --git a/src/wp-includes/class-wp-secrets-libsodium-provider.php b/src/wp-includes/class-wp-secrets-libsodium-provider.php new file mode 100644 index 0000000000000..d096c2bac498b --- /dev/null +++ b/src/wp-includes/class-wp-secrets-libsodium-provider.php @@ -0,0 +1,668 @@ +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() { + $keyring = $this->key_manager->get_keyring(); + + return sprintf( + /* translators: %s: Human-readable description of the key source. */ + __( 'WordPress (libsodium), key source: %s', 'default' ), + $keyring->get_key_source() + ); + } + + /** + * Protection happens inside WordPress for this provider. + * + * @since 7.2.0 + * + * @return string + */ + public function get_protection_boundary() { + 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() { + 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.', 'default' ) + ); + } + + 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.', 'default' ), + 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.', 'default' ) + ); + } + + 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|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( $store, $name, $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 ) { + if ( ! is_array( $record ) || ! isset( $record['current']['fingerprint'] ) || ! is_string( $record['current']['fingerprint'] ) ) { + return ''; + } + + return $record['current']['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 $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. + */ + private function demote_slot( $cipher, $master_key, $scope, $site_id, $name, $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'] ) ? $current_slot['created'] : time(); + $demoted['needs_rotation'] = isset( $current_slot['needs_rotation'] ) ? $current_slot['needs_rotation'] : false; + + 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( $name, $value, $network = false, $needs_rotation = false, $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.', 'default' ) + ); + } + + $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. + */ + 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'] + ); + + 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 + */ + public function get( $name, $version, $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( $name, $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 src/wp-includes/secrets.php */ + do_action( + 'wp_secret_changed', + $name, + 'deleted', + get_current_user_id(), + time(), + $this->stored_fingerprint( $existing ), + '' + ); + } + + 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( $name, $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 = isset( $record['previous']['fingerprint'] ) && is_string( $record['previous']['fingerprint'] ) + ? $record['previous']['fingerprint'] + : ''; + + unset( $record['previous'] ); + + $result = $store->set( $name, $record, $network ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + /** This action is documented in src/wp-includes/secrets.php */ + do_action( + 'wp_secret_changed', + $name, + 'retired', + get_current_user_id(), + time(), + $retired_fingerprint, + '' + ); + + 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. + */ + public function list_secrets( $name_prefix = '', $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 ) ) { + $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; + } +} diff --git a/src/wp-includes/class-wp-secrets-option-store.php b/src/wp-includes/class-wp-secrets-option-store.php new file mode 100644 index 0000000000000..843afd1a896d7 --- /dev/null +++ b/src/wp-includes/class-wp-secrets-option-store.php @@ -0,0 +1,213 @@ +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.', 'default' ) + ); + } + + return $value; + } + + /** + * Writes a secret's record. + * + * @since 7.2.0 + * + * @param string $name The secret's namespaced name. + * @param array $record The record to store. + * @param bool $network Whether this is a network-scope secret. + * + * @return true|WP_Error + */ + public function set( $name, $record, $network = false ) { + if ( ! is_array( $record ) ) { + return new WP_Error( + WP_SECRETS_ERROR_RECORD_MALFORMED, + __( 'A secret record must be an array.', 'default' ) + ); + } + + $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.', 'default' ) + ); + } + + /** + * 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( $name, $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.', 'default' ) + ); + } + + /** + * Lists the names of every secret in this store, for this scope. + * + * @since 7.2.0 + * + * @param bool $network Whether to list network-scope secrets. + * + * @return array|WP_Error + */ + public function list_names( $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.', 'default' ) + ); + } + + $names = array(); + + foreach ( $option_names as $option_name ) { + $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( $name, $network ) { + return ( $network ? self::NETWORK_PREFIX : self::SITE_PREFIX ) . $name; + } +} diff --git a/src/wp-includes/interface-wp-secrets-keyring.php b/src/wp-includes/interface-wp-secrets-keyring.php new file mode 100644 index 0000000000000..3fe3c5b6a7e45 --- /dev/null +++ b/src/wp-includes/interface-wp-secrets-keyring.php @@ -0,0 +1,60 @@ + 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.', 'default' ), + 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.', 'default' ) + ); + } + + $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.', 'default' ) + ); + } + + /* + * 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.', 'default' ), + $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.', 'default' ) + ); + } + + 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.', 'default' ) + ); + } + + 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() { + static $store = null; + + if ( null !== $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() { + static $key_manager = null; + + if ( null !== $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() { + 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( $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( $option, $name ) { + if ( ! is_string( $option ) || '' === $option ) { + return new WP_Error( + WP_SECRETS_ERROR_INVALID_VALUE, + __( 'Option name must be a non-empty string.', 'default' ) + ); + } + + $value = get_option( $option, null ); + + if ( null === $value ) { + return new WP_Error( + WP_SECRETS_ERROR_INVALID_VALUE, + __( 'The option does not exist.', 'default' ) + ); + } + + 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.', 'default' ) + ); + } + + 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 + */ +function wp_get_secret( $name, $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( $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( $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'. + */ +function wp_list_secrets( $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( $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 + */ +function wp_get_network_secret( $name, $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( $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( $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 + */ +function wp_list_network_secrets( $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() { + static $provider = null; + + if ( null !== $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() { + 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() { + 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( $name, $value, $network, $needs_rotation = false, $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 + */ +function _wp_secrets_get( $name, $version, $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.', 'default' ), + '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.', 'default' ) + ); + } + + 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( $name, $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( $name, $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 + */ +function _wp_secrets_list( $name_prefix, $network ) { + // See _wp_secrets_get() for why this is checked here and not per-provider. + if ( ! is_string( $name_prefix ) ) { + _doing_it_wrong( + __FUNCTION__, + __( 'The namespace must be a string.', 'default' ), + '7.2.0' + ); + + return new WP_Error( + WP_SECRETS_ERROR_INVALID_ARGUMENT, + __( 'The namespace must be a string.', 'default' ) + ); + } + + return _wp_secrets_get_provider()->list_secrets( $name_prefix, $network ); +} From 067c6c0af14fb7de69e0fc36c65f7b67dbeaeca3 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 25 Sep 2026 12:01:33 -0700 Subject: [PATCH 02/12] Secrets API: load the API, the secrets.php drop-in, and the capabilities - wp-settings.php loads the API next to application passwords, and loads the secrets.php drop-in just before must-use plugins, once every interface a drop-in implements exists. - wp_load_secrets_dropin() in load.php marks the drop-in broken when it throws or sets a provider, store, or keyring global of the wrong type, so the API fails closed instead of falling back to the default. - class-wp-site-health.php loads the Secrets API's Site Health tests, so every path that loads WP_Site_Health gets them. - populate_roles_720() grants manage_secrets to administrators, and upgrade_720() does the same for existing sites (db version 61900; the committer should set the real revision). - map_meta_cap() lists manage_network_secrets with the other network capabilities, which only super admins hold. --- .../includes/class-wp-site-health.php | 3 + src/wp-admin/includes/schema.php | 14 ++++ src/wp-admin/includes/upgrade.php | 21 ++++++ src/wp-includes/capabilities.php | 1 + src/wp-includes/load.php | 64 +++++++++++++++++++ src/wp-includes/version.php | 2 +- src/wp-settings.php | 17 +++++ 7 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 31e940b2076e1..de0d7cd8da332 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -7,6 +7,9 @@ * @since 5.2.0 */ + +require_once __DIR__ . '/secrets-site-health.php'; + #[AllowDynamicProperties] class WP_Site_Health { private static $instance = null; diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 8ea0b3680291c..0cca17dffe3ef 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -732,6 +732,7 @@ function populate_roles() { populate_roles_270(); populate_roles_280(); populate_roles_300(); + populate_roles_720(); // Save the updated roles to the database. if ( $original_use_db ) { @@ -968,6 +969,19 @@ function populate_roles_300() { } } +/** + * Create and modify WordPress roles for WordPress 7.2. + * + * @since 7.2.0 + */ +function populate_roles_720() { + $role = get_role( 'administrator' ); + + if ( ! empty( $role ) ) { + $role->add_cap( 'manage_secrets' ); + } +} + if ( ! function_exists( 'install_network' ) ) : /** * Install Network. diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 244401cf24013..40c695e135b05 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -890,6 +890,10 @@ function upgrade_all() { upgrade_700(); } + if ( $wp_current_db_version < 61900 ) { + upgrade_720(); + } + maybe_disable_link_manager(); maybe_disable_automattic_widgets(); @@ -2500,6 +2504,23 @@ function upgrade_700() { } } +/** + * 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() { + global $wp_current_db_version; + + // Grant the Secrets API's site-level capability to administrators. + if ( $wp_current_db_version < 61900 ) { + populate_roles_720(); + } +} + /** * Executes network-level upgrade routines. * diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 6f8b57d50b348..09baaf189989e 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -767,6 +767,7 @@ function map_meta_cap( $cap, $user_id, ...$args ) { case 'manage_network_plugins': case 'manage_network_themes': case 'manage_network_options': + case 'manage_network_secrets': case 'upgrade_network': $caps[] = $cap; break; diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 061754e8b4e52..1b8d25a941338 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -819,6 +819,70 @@ function wp_using_ext_object_cache( $using = null ) { return $current_using; } +/** + * 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() { + 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. * diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 0b5627887c4d3..2b1ebaf3787a5 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -23,7 +23,7 @@ * * @global int $wp_db_version */ -$wp_db_version = 61833; +$wp_db_version = 61900; /** * Holds the TinyMCE version. diff --git a/src/wp-settings.php b/src/wp-settings.php index 1d55db83e2d85..6a6bca84a39d9 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -308,6 +308,20 @@ require ABSPATH . WPINC . '/nav-menu.php'; require ABSPATH . WPINC . '/admin-bar.php'; require ABSPATH . WPINC . '/class-wp-application-passwords.php'; +require ABSPATH . WPINC . '/secrets.php'; +require ABSPATH . WPINC . '/class-wp-secret-version.php'; +require ABSPATH . WPINC . '/class-wp-secret.php'; +require ABSPATH . WPINC . '/interface-wp-secrets-provider.php'; +require ABSPATH . WPINC . '/interface-wp-secrets-keyring.php'; +require ABSPATH . WPINC . '/interface-wp-secrets-store.php'; +require ABSPATH . WPINC . '/class-wp-secrets-config-key-provider.php'; +require ABSPATH . WPINC . '/class-wp-secrets-broken-keyring.php'; +require ABSPATH . WPINC . '/class-wp-secrets-cipher.php'; +require ABSPATH . WPINC . '/class-wp-secrets-key-manager.php'; +require ABSPATH . WPINC . '/class-wp-secrets-option-store.php'; +require ABSPATH . WPINC . '/class-wp-secrets-broken-store.php'; +require ABSPATH . WPINC . '/class-wp-secrets-libsodium-provider.php'; +require ABSPATH . WPINC . '/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'; require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php'; @@ -505,6 +519,9 @@ // To make get_plugin_data() available for both network-activated and site-activated plugins, see #62244 and #64249. 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 ) { $_wp_plugin_file = $mu_plugin; From e3f2f22b20974c31c3941499758a7a0aa6325e82 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 25 Sep 2026 12:01:33 -0700 Subject: [PATCH 03/12] Secrets API: tests Ports the feature plugin's tests for everything under src/ into tests/phpunit/tests/secrets/, with its shared helpers (assertions trait, mock store and keyring, provider and keyring conformance suites) in tests/phpunit/includes/secrets/. The plugin-only tests (prototype upgrade path, WP-CLI, architecture, no-op gate) stay behind. wp-tests-config-sample.php ships placeholder salts, which the config keyring refuses by design, so the test bootstrap defines a fixed WP_SECRETS_KEY in the main PHPUnit process. Process-isolated tests set or unset the key themselves; the four that exercise the LOGGED_IN salt fallback skip when the salts are placeholders. Also registers manage_secrets and manage_network_secrets in the capability inventory test, and adds tests for populate_roles_720(), the upgrade_720() gate, and manage_network_secrets on multisite. --- tests/phpunit/includes/bootstrap.php | 20 + .../includes/secrets/class-mock-keyring.php | 97 ++++ .../includes/secrets/class-mock-store.php | 94 ++++ .../class-wp-secrets-keyring-conformance.php | 112 +++++ .../class-wp-secrets-provider-conformance.php | 248 ++++++++++ .../secrets/trait-wp-secrets-assertions.php | 83 ++++ .../tests/secrets/brokenDropinFallbacks.php | 78 ++++ tests/phpunit/tests/secrets/capabilities.php | 114 +++++ .../secrets/configKeyringConformance.php | 17 + tests/phpunit/tests/secrets/errorCodes.php | 39 ++ .../phpunit/tests/secrets/extensionPoints.php | 187 ++++++++ .../tests/secrets/mockKeyringConformance.php | 17 + tests/phpunit/tests/secrets/networkScope.php | 127 +++++ tests/phpunit/tests/secrets/provider.php | 231 ++++++++++ .../tests/secrets/providerConformance.php | 19 + .../secrets/readonlyProviderConformance.php | 52 +++ tests/phpunit/tests/secrets/siteHealth.php | 198 ++++++++ .../tests/secrets/threeStateContract.php | 187 ++++++++ tests/phpunit/tests/secrets/versionSlots.php | 189 ++++++++ .../phpunit/tests/secrets/wpDeleteSecret.php | 83 ++++ .../tests/secrets/wpImportOptionAsSecret.php | 101 ++++ tests/phpunit/tests/secrets/wpListSecrets.php | 122 +++++ tests/phpunit/tests/secrets/wpSecret.php | 326 +++++++++++++ .../phpunit/tests/secrets/wpSecretVersion.php | 30 ++ .../phpunit/tests/secrets/wpSecretsCipher.php | 342 ++++++++++++++ .../secrets/wpSecretsConfigKeyProvider.php | 313 +++++++++++++ .../tests/secrets/wpSecretsKeyManager.php | 433 ++++++++++++++++++ .../tests/secrets/wpSecretsMemzero.php | 43 ++ .../tests/secrets/wpSecretsOptionStore.php | 160 +++++++ .../tests/secrets/wpSecretsValidateName.php | 96 ++++ .../secrets/wpSetSecretAndWpGetSecret.php | 212 +++++++++ tests/phpunit/tests/user/capabilities.php | 4 + 32 files changed, 4374 insertions(+) create mode 100644 tests/phpunit/includes/secrets/class-mock-keyring.php create mode 100644 tests/phpunit/includes/secrets/class-mock-store.php create mode 100644 tests/phpunit/includes/secrets/class-wp-secrets-keyring-conformance.php create mode 100644 tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php create mode 100644 tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php create mode 100644 tests/phpunit/tests/secrets/brokenDropinFallbacks.php create mode 100644 tests/phpunit/tests/secrets/capabilities.php create mode 100644 tests/phpunit/tests/secrets/configKeyringConformance.php create mode 100644 tests/phpunit/tests/secrets/errorCodes.php create mode 100644 tests/phpunit/tests/secrets/extensionPoints.php create mode 100644 tests/phpunit/tests/secrets/mockKeyringConformance.php create mode 100644 tests/phpunit/tests/secrets/networkScope.php create mode 100644 tests/phpunit/tests/secrets/provider.php create mode 100644 tests/phpunit/tests/secrets/providerConformance.php create mode 100644 tests/phpunit/tests/secrets/readonlyProviderConformance.php create mode 100644 tests/phpunit/tests/secrets/siteHealth.php create mode 100644 tests/phpunit/tests/secrets/threeStateContract.php create mode 100644 tests/phpunit/tests/secrets/versionSlots.php create mode 100644 tests/phpunit/tests/secrets/wpDeleteSecret.php create mode 100644 tests/phpunit/tests/secrets/wpImportOptionAsSecret.php create mode 100644 tests/phpunit/tests/secrets/wpListSecrets.php create mode 100644 tests/phpunit/tests/secrets/wpSecret.php create mode 100644 tests/phpunit/tests/secrets/wpSecretVersion.php create mode 100644 tests/phpunit/tests/secrets/wpSecretsCipher.php create mode 100644 tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php create mode 100644 tests/phpunit/tests/secrets/wpSecretsKeyManager.php create mode 100644 tests/phpunit/tests/secrets/wpSecretsMemzero.php create mode 100644 tests/phpunit/tests/secrets/wpSecretsOptionStore.php create mode 100644 tests/phpunit/tests/secrets/wpSecretsValidateName.php create mode 100644 tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php diff --git a/tests/phpunit/includes/bootstrap.php b/tests/phpunit/includes/bootstrap.php index b3835a1ce415b..0405208005ee8 100644 --- a/tests/phpunit/includes/bootstrap.php +++ b/tests/phpunit/includes/bootstrap.php @@ -28,6 +28,21 @@ } 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'; if ( defined( 'WP_RUN_CORE_TESTS' ) && WP_RUN_CORE_TESTS && ! is_dir( ABSPATH ) ) { @@ -333,6 +348,11 @@ function wp_tests_options( $value ) { require __DIR__ . '/class-wp-sitemaps-test-provider.php'; 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'; /** * A class to handle additional command line arguments passed to the script. diff --git a/tests/phpunit/includes/secrets/class-mock-keyring.php b/tests/phpunit/includes/secrets/class-mock-keyring.php new file mode 100644 index 0000000000000..a4925e226c005 --- /dev/null +++ b/tests/phpunit/includes/secrets/class-mock-keyring.php @@ -0,0 +1,97 @@ +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( $wrapped ) { + ++$this->unwrap_calls; + + if ( $this->fail_unwrap ) { + return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, 'Mock_Keyring: unwrap() configured to fail.' ); + } + + if ( ! is_string( $wrapped ) || 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() { + return 'mock keyring'; + } + + /** + * @param bool $fail Whether wrap() should return WP_Error. + * + * @return $this + */ + public function configure_fail_wrap( $fail = true ) { + $this->fail_wrap = $fail; + + return $this; + } + + /** + * @param bool $fail Whether unwrap() should return WP_Error. + * + * @return $this + */ + public function configure_fail_unwrap( $fail = true ) { + $this->fail_unwrap = $fail; + + return $this; + } + + /** + * @return int Number of times wrap() has been called. + */ + public function wrap_call_count() { + return $this->wrap_calls; + } + + /** + * @return int Number of times unwrap() has been called. + */ + public function unwrap_call_count() { + return $this->unwrap_calls; + } +} diff --git a/tests/phpunit/includes/secrets/class-mock-store.php b/tests/phpunit/includes/secrets/class-mock-store.php new file mode 100644 index 0000000000000..0daaa193f7ffa --- /dev/null +++ b/tests/phpunit/includes/secrets/class-mock-store.php @@ -0,0 +1,94 @@ + false, + 'set' => false, + 'delete' => false, + 'list' => false, + ); + private $received_records = array(); + + public function get( $name, $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( $name, $record, $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( $name, $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( $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( $operation, $fail = true ) { + $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 + */ + public function get_received_records() { + return $this->received_records; + } + + private function key( $name, $network ) { + return ( $network ? 'network:' : 'site:' ) . $name; + } +} diff --git a/tests/phpunit/includes/secrets/class-wp-secrets-keyring-conformance.php b/tests/phpunit/includes/secrets/class-wp-secrets-keyring-conformance.php new file mode 100644 index 0000000000000..331911ee16472 --- /dev/null +++ b/tests/phpunit/includes/secrets/class-wp-secrets-keyring-conformance.php @@ -0,0 +1,112 @@ +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() { + $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() { + $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() { + $keyring = $this->keyring(); + $wrapped = $keyring->wrap( random_bytes( 32 ) ); + + $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() { + $keyring = $this->keyring(); + $wrapped = $keyring->wrap( random_bytes( 32 ) ); + + $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() { + $source = $this->keyring()->get_key_source(); + + $this->assertIsString( $source ); + $this->assertNotSame( '', trim( $source ) ); + } +} diff --git a/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php b/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php new file mode 100644 index 0000000000000..e26d13759b6f3 --- /dev/null +++ b/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php @@ -0,0 +1,248 @@ +is_writable() ) { + $this->markTestSkipped( 'Provider declares itself read-only; write-path conformance does not apply.' ); + } + } + + // -- declarations must be coherent ---------------------------------------- + + public function test_reports_a_non_empty_label() { + $label = $this->provider()->get_label(); + + $this->assertIsString( $label ); + $this->assertNotSame( '', trim( $label ) ); + } + + public function test_reports_a_known_protection_boundary() { + $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() { + $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() { + $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() { + $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() { + $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() { + $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() { + $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() { + $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() { + $provider = $this->provider(); + $this->require_writable( $provider ); + + $name = $this->conformance_name(); + $provider->set( $name, 'stable-value' ); + + $first = $provider->get( $name, WP_Secret_Version::CURRENT )->fingerprint(); + $second = $provider->get( $name, WP_Secret_Version::CURRENT )->fingerprint(); + + $this->assertIsString( $first ); + $this->assertNotSame( '', $first ); + $this->assertSame( $first, $second ); + } + + public function test_a_deleted_secret_reads_as_absent() { + $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 ) ); + } + + // -- listing never leaks --------------------------------------------------- + + public function test_listing_returns_an_array_or_an_error_and_never_a_value() { + $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 ); + $this->assertStringNotContainsString( + 'UNIQUE-CONFORMANCE-CANARY-3f9b', + wp_json_encode( $entries ), + 'list_secrets() must never expose a plaintext.' + ); + } + + public function test_listing_includes_a_stored_secret() { + $provider = $this->provider(); + $this->require_writable( $provider ); + + $name = $this->conformance_name(); + $provider->set( $name, 'value' ); + + $names = wp_list_pluck( $provider->list_secrets(), 'name' ); + + $this->assertContains( $name, $names ); + } + + /** + * 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() { + $provider = $this->provider(); + $this->require_writable( $provider ); + + $provider->set( 'conformance-a/one', 'value' ); + $provider->set( 'conformance-b/two', 'value' ); + + $names = wp_list_pluck( $provider->list_secrets( 'conformance-a' ), 'name' ); + + $this->assertContains( 'conformance-a/one', $names ); + $this->assertNotContains( 'conformance-b/two', $names ); + } +} diff --git a/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php b/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php new file mode 100644 index 0000000000000..8b9c180b3b6df --- /dev/null +++ b/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php @@ -0,0 +1,83 @@ +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. + */ + public function assertRecordSlotDecryptsTo( $name, $slot, $expected, $network = false ) { // 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 = '' ) { // 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.' + ); + } +} diff --git a/tests/phpunit/tests/secrets/brokenDropinFallbacks.php b/tests/phpunit/tests/secrets/brokenDropinFallbacks.php new file mode 100644 index 0000000000000..038c5b89e89ce --- /dev/null +++ b/tests/phpunit/tests/secrets/brokenDropinFallbacks.php @@ -0,0 +1,78 @@ +assertInstanceOf( WP_Secrets_Store::class, new WP_Secrets_Broken_Store() ); + } + + public function test_broken_store_get_fails() { + $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() { + $result = ( new WP_Secrets_Broken_Store() )->set( 'myplugin/api-key', array() ); + + $this->assertWPError( $result ); + } + + public function test_broken_store_delete_fails() { + $result = ( new WP_Secrets_Broken_Store() )->delete( 'myplugin/api-key' ); + + $this->assertWPError( $result ); + } + + public function test_broken_store_list_names_fails() { + $result = ( new WP_Secrets_Broken_Store() )->list_names(); + + $this->assertWPError( $result ); + } + + + public function test_broken_keyring_implements_the_interface() { + $this->assertInstanceOf( WP_Secrets_Keyring::class, new WP_Secrets_Broken_Keyring() ); + } + + public function test_broken_keyring_wrap_fails() { + $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() { + $result = ( new WP_Secrets_Broken_Keyring() )->unwrap( 'wrapped' ); + + $this->assertWPError( $result ); + } + + public function test_broken_keyring_reports_a_key_source() { + $this->assertIsString( ( new WP_Secrets_Broken_Keyring() )->get_key_source() ); + } + + public function test_wp_using_secrets_dropin_is_false_by_default() { + $this->assertFalse( wp_using_secrets_dropin() ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_wp_using_secrets_dropin_reports_true_once_the_loader_flag_is_set() { + // 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() ); + } +} diff --git a/tests/phpunit/tests/secrets/capabilities.php b/tests/phpunit/tests/secrets/capabilities.php new file mode 100644 index 0000000000000..5be5bb155c8a0 --- /dev/null +++ b/tests/phpunit/tests/secrets/capabilities.php @@ -0,0 +1,114 @@ +add_cap( WP_SECRETS_CAP_MANAGE ); + } + + parent::tear_down(); + } + + public function test_capability_constant_values() { + $this->assertSame( 'manage_secrets', WP_SECRETS_CAP_MANAGE ); + $this->assertSame( 'manage_network_secrets', WP_SECRETS_CAP_MANAGE_NETWORK ); + } + + public function test_a_fresh_install_grants_manage_secrets_to_administrator() { + $this->assertTrue( get_role( 'administrator' )->has_cap( WP_SECRETS_CAP_MANAGE ) ); + } + + public function test_no_other_role_is_granted_manage_secrets() { + foreach ( array( 'editor', 'author', 'contributor', 'subscriber' ) as $role ) { + $this->assertFalse( get_role( $role )->has_cap( WP_SECRETS_CAP_MANAGE ), $role ); + } + } + + public function test_populate_roles_720_grants_manage_secrets_to_administrator() { + get_role( 'administrator' )->remove_cap( WP_SECRETS_CAP_MANAGE ); + + populate_roles_720(); + + $this->assertTrue( get_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 ) { + global $wp_current_db_version; + + $saved = $wp_current_db_version; + $wp_current_db_version = $db_version; + + get_role( 'administrator' )->remove_cap( WP_SECRETS_CAP_MANAGE ); + + upgrade_720(); + + $wp_current_db_version = $saved; + + $this->assertSame( $expect_granted, get_role( 'administrator' )->has_cap( WP_SECRETS_CAP_MANAGE ) ); + } + + public function data_upgrade_720_gate() { + return array( + 'upgrading from 7.0' => array( 61644, true ), + 'upgrading from just below 7.2' => array( 61899, true ), + 'already at the 7.2 db version' => array( 61900, false ), + 'already past the 7.2 db version' => array( 62000, false ), + ); + } + + public function test_administrator_user_has_manage_secrets() { + $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() { + $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() { + $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() { + $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 ) ); + } +} diff --git a/tests/phpunit/tests/secrets/configKeyringConformance.php b/tests/phpunit/tests/secrets/configKeyringConformance.php new file mode 100644 index 0000000000000..51f486fecb72d --- /dev/null +++ b/tests/phpunit/tests/secrets/configKeyringConformance.php @@ -0,0 +1,17 @@ +assertSame( 'secret_decryption_failed', WP_SECRETS_ERROR_DECRYPTION_FAILED ); + $this->assertSame( 'secret_key_unavailable', WP_SECRETS_ERROR_KEY_UNAVAILABLE ); + $this->assertSame( 'secret_store_unavailable', WP_SECRETS_ERROR_STORE_UNAVAILABLE ); + $this->assertSame( 'secret_invalid_name', WP_SECRETS_ERROR_INVALID_NAME ); + $this->assertSame( 'secret_invalid_value', WP_SECRETS_ERROR_INVALID_VALUE ); + $this->assertSame( 'secret_crypto_unavailable', WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE ); + $this->assertSame( 'secret_record_malformed', WP_SECRETS_ERROR_RECORD_MALFORMED ); + } + + public function test_all_error_codes_are_distinct() { + $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() { + // 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 ); + } +} diff --git a/tests/phpunit/tests/secrets/extensionPoints.php b/tests/phpunit/tests/secrets/extensionPoints.php new file mode 100644 index 0000000000000..c4011ab1ecf00 --- /dev/null +++ b/tests/phpunit/tests/secrets/extensionPoints.php @@ -0,0 +1,187 @@ +assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) ); + $this->assertSame( 'value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_mock_keyring_with_the_default_store_round_trips() { + $GLOBALS['wp_secrets_keyring'] = new Mock_Keyring(); + + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) ); + $this->assertSame( 'value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_mock_store_and_mock_keyring_together_round_trip() { + $GLOBALS['wp_secrets_store'] = new Mock_Store(); + $GLOBALS['wp_secrets_keyring'] = new Mock_Keyring(); + + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) ); + $this->assertSame( 'value', wp_get_secret( 'myplugin/api-key' )->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() { + // 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() { + // 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' ) ); + $this->assertSame( 'value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_unreachable_store_fails_closed_with_no_local_fallback() { + $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() { + $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() { + $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() { + $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() ); + } +} diff --git a/tests/phpunit/tests/secrets/mockKeyringConformance.php b/tests/phpunit/tests/secrets/mockKeyringConformance.php new file mode 100644 index 0000000000000..2c9911e649a94 --- /dev/null +++ b/tests/phpunit/tests/secrets/mockKeyringConformance.php @@ -0,0 +1,17 @@ +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() { + $this->assertNull( wp_get_network_secret( 'myplugin/never-set' ) ); + } + + public function test_site_and_network_scope_are_independent_under_the_same_name() { + wp_set_secret( 'myplugin/api-key', 'site-value' ); + wp_set_network_secret( 'myplugin/api-key', 'network-value' ); + + $this->assertSame( 'site-value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + $this->assertSame( 'network-value', wp_get_network_secret( 'myplugin/api-key' )->reveal() ); + } + + public function test_delete_removes_a_network_secret() { + 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() { + wp_set_secret( 'myplugin/api-key', 'site-value' ); + wp_set_network_secret( 'myplugin/api-key', 'network-value' ); + + wp_delete_network_secret( 'myplugin/api-key' ); + + $this->assertSame( 'site-value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + } + + public function test_previous_version_and_demotion_work_the_same_as_site_scope() { + 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() { + 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 ) ); + } + + public function test_list_returns_network_secrets_only() { + wp_set_secret( 'myplugin/site-only', 'value' ); + wp_set_network_secret( 'myplugin/network-only', 'value' ); + + $names = wp_list_pluck( wp_list_network_secrets(), 'name' ); + + $this->assertSame( array( 'myplugin/network-only' ), $names ); + } + + public function test_an_invalid_version_is_a_wp_error_same_as_site_scope() { + $this->setExpectedIncorrectUsage( '_wp_secrets_get' ); + + $result = wp_get_network_secret( 'myplugin/api-key', 'not-a-real-version' ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); + } + + public function test_set_rejects_an_invalid_name() { + $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() { + 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() { + 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 ); + } +} diff --git a/tests/phpunit/tests/secrets/provider.php b/tests/phpunit/tests/secrets/provider.php new file mode 100644 index 0000000000000..bba82431f0a45 --- /dev/null +++ b/tests/phpunit/tests/secrets/provider.php @@ -0,0 +1,231 @@ +assertInstanceOf( 'WP_Secrets_Libsodium_Provider', _wp_secrets_get_provider() ); + } + + public function test_the_default_provider_protects_inside_wordpress() { + $this->assertSame( + WP_Secrets_Provider::BOUNDARY_WORDPRESS, + _wp_secrets_get_provider()->get_protection_boundary() + ); + } + + public function test_the_default_provider_accepts_writes() { + $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() { + $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() { + $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() { + $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() { + $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() { + $GLOBALS['wp_secrets_dropin_broken'] = true; + + $provider = _wp_secrets_get_provider(); + + $this->assertInstanceOf( 'WP_Secrets_Broken_Provider', $provider ); + $this->assertNotInstanceOf( 'WP_Secrets_Libsodium_Provider', $provider ); + $this->assertFalse( $provider->is_writable() ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_a_broken_dropin_makes_every_operation_a_wp_error() { + $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() { + $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() { + $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() { + $GLOBALS['wp_secrets_provider'] = new stdClass(); + + $result = wp_get_secret( 'myplugin/api-key' ); + + $this->assertWPError( $result ); + $this->assertNotNull( $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() { + $GLOBALS['wp_secrets_keyring'] = new Mock_Keyring(); + + $this->assertInstanceOf( 'WP_Secrets_Libsodium_Provider', _wp_secrets_get_provider() ); + } +} diff --git a/tests/phpunit/tests/secrets/providerConformance.php b/tests/phpunit/tests/secrets/providerConformance.php new file mode 100644 index 0000000000000..dafcb49763baa --- /dev/null +++ b/tests/phpunit/tests/secrets/providerConformance.php @@ -0,0 +1,19 @@ +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->assertTrue( function_exists( $test['test'] ), "Registered test callback \"{$test['test']}\" for \"{$key}\" does not exist." ); + } + } + + public function test_preserves_existing_tests() { + $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() { + 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() { + 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() { + 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() { + $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() { + 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() { + $result = wp_secrets_site_health_test_undecryptable(); + + $this->assertSame( 'good', $result['status'] ); + } + + public function test_undecryptable_test_is_critical_and_lists_the_broken_secret() { + wp_set_secret( 'myplugin/api-key', 'value' ); + $fingerprint = wp_get_secret( 'myplugin/api-key' )->fingerprint(); + + $record = get_option( '_wp_secret_myplugin/api-key' ); + $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() { + wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ); + + $record = get_option( '_wp_secret_myplugin/api-key' ); + $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() { + 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() { + 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() { + wp_set_secret( 'myplugin/api-key', 'value' ); + + $info = wp_secrets_site_health_debug_info( array() ); + + $this->assertArrayHasKey( 'secrets-api', $info ); + $fields = $info['secrets-api']['fields']; + + $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() { + $info = wp_secrets_site_health_debug_info( array() ); + + $this->assertSame( 'No', $info['secrets-api']['fields']['dropin_active']['value'] ); + } + + public function test_debug_info_never_contains_a_plaintext() { + 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->assertStringNotContainsString( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $dump ); + } + + public function test_debug_info_omits_network_fields_on_single_site() { + $info = wp_secrets_site_health_debug_info( array() ); + + $this->assertArrayNotHasKey( 'network_secret_count', $info['secrets-api']['fields'] ); + } + + /** + * 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() { + 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', $info['secrets-api']['fields'], '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', $info['secrets-api']['fields'] ); + } +} diff --git a/tests/phpunit/tests/secrets/threeStateContract.php b/tests/phpunit/tests/secrets/threeStateContract.php new file mode 100644 index 0000000000000..9037a8a1b3df7 --- /dev/null +++ b/tests/phpunit/tests/secrets/threeStateContract.php @@ -0,0 +1,187 @@ +assertNull( $result ); + } + + public function test_previous_slot_absent_on_an_existing_secret_is_also_null() { + 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() { + 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() { + 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() { + 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() { + 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() { + 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() { + 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() { + wp_set_secret( 'myplugin/api-key', 'value' ); + + $record = get_option( '_wp_secret_myplugin/api-key' ); + 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() { + wp_set_secret( 'myplugin/api-key', 'value' ); + $genuine = wp_get_secret( 'myplugin/api-key' )->fingerprint(); + + $record = get_option( '_wp_secret_myplugin/api-key' ); + $record['current']['fingerprint'] = 'deadbeefdeadbeefdeadbeefdeadbeef'; + update_option( '_wp_secret_myplugin/api-key', $record, false ); + + $this->assertSame( $genuine, wp_get_secret( 'myplugin/api-key' )->fingerprint() ); + } + + public function test_exists_and_decrypts_is_a_wp_secret() { + 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 ); + } +} diff --git a/tests/phpunit/tests/secrets/versionSlots.php b/tests/phpunit/tests/secrets/versionSlots.php new file mode 100644 index 0000000000000..466ee831c9291 --- /dev/null +++ b/tests/phpunit/tests/secrets/versionSlots.php @@ -0,0 +1,189 @@ +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() { + 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() { + 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() { + wp_set_secret( 'myplugin/api-key', 'first-value' ); + $original_fingerprint = wp_get_secret( 'myplugin/api-key' )->fingerprint(); + + wp_set_secret( 'myplugin/api-key', 'second-value' ); + + $this->assertSame( $original_fingerprint, wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS )->fingerprint() ); + } + + public function test_previous_preserves_its_original_created_timestamp() { + wp_set_secret( 'myplugin/api-key', 'first-value' ); + $original_created = get_option( '_wp_secret_myplugin/api-key' )['current']['created']; + + wp_set_secret( 'myplugin/api-key', 'second-value' ); + + $demoted_created = get_option( '_wp_secret_myplugin/api-key' )['previous']['created']; + + $this->assertSame( $original_created, $demoted_created ); + } + + public function test_previous_preserves_its_needs_rotation_flag() { + 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' ); + $record['current']['needs_rotation'] = true; + update_option( '_wp_secret_myplugin/api-key', $record, false ); + + wp_set_secret( 'myplugin/api-key', 'second-value' ); + + $this->assertTrue( get_option( '_wp_secret_myplugin/api-key' )['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() { + wp_set_secret( 'myplugin/api-key', 'first-value' ); + $original_ciphertext = get_option( '_wp_secret_myplugin/api-key' )['current']['ct']; + + wp_set_secret( 'myplugin/api-key', 'second-value' ); + + $demoted_ciphertext = get_option( '_wp_secret_myplugin/api-key' )['previous']['ct']; + + $this->assertNotSame( $original_ciphertext, $demoted_ciphertext ); + // And it must still actually decrypt to the original plaintext. + $this->assertSame( 'first-value', wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS )->reveal() ); + } + + public function test_previous_on_a_secret_with_no_previous_slot_is_null() { + 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() { + wp_set_secret( 'myplugin/api-key', 'value' ); + + $record = get_option( '_wp_secret_myplugin/api-key' ); + $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' ) ); + $this->assertSame( 'new-value', wp_get_secret( 'myplugin/api-key' )->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() { + 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 ) ); + $this->assertSame( 'second-value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + } + + public function test_retire_on_a_secret_with_no_previous_slot_is_a_successful_noop() { + 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() { + $this->assertTrue( wp_retire_secret_version( 'myplugin/never-set' ) ); + } + + public function test_retire_rejects_an_invalid_name() { + $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() { + wp_set_secret( 'myplugin/api-key', 'first-value' ); + $fingerprint_being_retired = wp_get_secret( 'myplugin/api-key' )->fingerprint(); + wp_set_secret( 'myplugin/api-key', 'second-value' ); + + $captured = null; + add_action( + 'wp_secret_changed', + function ( ...$args ) use ( &$captured ) { + $captured = $args; + }, + 10, + 6 + ); + + wp_retire_secret_version( 'myplugin/api-key' ); + + 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() { + 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 ); + } +} diff --git a/tests/phpunit/tests/secrets/wpDeleteSecret.php b/tests/phpunit/tests/secrets/wpDeleteSecret.php new file mode 100644 index 0000000000000..5d2dd73f09660 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpDeleteSecret.php @@ -0,0 +1,83 @@ +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() { + $this->assertTrue( wp_delete_secret( 'myplugin/never-set' ) ); + } + + public function test_rejects_an_invalid_name() { + $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() { + wp_set_secret( 'myplugin/api-key', 'value' ); + $fingerprint = wp_get_secret( 'myplugin/api-key' )->fingerprint(); + + $captured = null; + add_action( + 'wp_secret_changed', + function ( ...$args ) use ( &$captured ) { + $captured = $args; + }, + 10, + 6 + ); + + wp_delete_secret( 'myplugin/api-key' ); + + 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() { + $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() { + 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() { + wp_set_secret( 'myplugin/api-key', 'value' ); + wp_set_current_user( 0 ); + + $this->assertTrue( wp_delete_secret( 'myplugin/api-key' ) ); + } +} diff --git a/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php b/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php new file mode 100644 index 0000000000000..bd43deafea7f0 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php @@ -0,0 +1,101 @@ +assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ) ); + $this->assertSame( 'sk_live_from_an_option', wp_get_secret( 'myplugin/api-key' )->reveal() ); + } + + public function test_leaves_the_source_option_untouched() { + 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() { + 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->assertTrue( $record['current']['needs_rotation'] ); + } + + public function test_an_ordinary_write_does_not_flag_needs_rotation() { + wp_set_secret( 'myplugin/api-key', 'value' ); + + $record = get_option( '_wp_secret_myplugin/api-key' ); + + $this->assertFalse( $record['current']['needs_rotation'] ); + } + + public function test_rejects_a_nonexistent_option() { + $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() { + 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() { + 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() { + update_option( 'my_plugin_api_key', 'value' ); + + $captured = null; + add_action( + 'wp_secret_changed', + function ( ...$args ) use ( &$captured ) { + $captured = $args; + }, + 10, + 6 + ); + + wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ); + + 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() { + 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' ); + + $this->assertSame( 'imported-value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + $this->assertSame( 'original-value', wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS )->reveal() ); + } +} diff --git a/tests/phpunit/tests/secrets/wpListSecrets.php b/tests/phpunit/tests/secrets/wpListSecrets.php new file mode 100644 index 0000000000000..720c406c7cbe7 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpListSecrets.php @@ -0,0 +1,122 @@ +assertSame( array(), wp_list_secrets() ); + } + + public function test_lists_every_secret_with_the_expected_keys() { + wp_set_secret( 'myplugin/api-key', 'value' ); + + $entries = wp_list_secrets(); + + $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() { + wp_set_secret( 'myplugin/api-key', 'a-plaintext-value-that-must-not-leak' ); + + $dump = wp_json_encode( wp_list_secrets() ); + + $this->assertStringNotContainsString( 'a-plaintext-value-that-must-not-leak', $dump ); + } + + public function test_fingerprint_matches_the_secrets_own_fingerprint() { + wp_set_secret( 'myplugin/api-key', 'value' ); + + $fingerprint = wp_get_secret( 'myplugin/api-key' )->fingerprint(); + $entries = wp_list_secrets(); + + $this->assertSame( $fingerprint, $entries[0]['fingerprint'] ); + } + + public function test_has_previous_is_false_before_a_rotation_and_true_after() { + wp_set_secret( 'myplugin/api-key', 'first-value' ); + + $this->assertFalse( wp_list_secrets()[0]['has_previous'] ); + + wp_set_secret( 'myplugin/api-key', 'second-value' ); + + $this->assertTrue( wp_list_secrets()[0]['has_previous'] ); + } + + public function test_needs_rotation_reflects_the_current_slots_flag() { + wp_set_secret( 'myplugin/api-key', 'value' ); + + $record = get_option( '_wp_secret_myplugin/api-key' ); + $record['current']['needs_rotation'] = true; + update_option( '_wp_secret_myplugin/api-key', $record, false ); + + $this->assertTrue( wp_list_secrets()[0]['needs_rotation'] ); + } + + public function test_filters_by_namespace() { + wp_set_secret( 'pluginone/key', 'value' ); + wp_set_secret( 'plugintwo/key', 'value' ); + + $names = wp_list_pluck( wp_list_secrets( 'pluginone' ), 'name' ); + + $this->assertSame( array( 'pluginone/key' ), $names ); + } + + public function test_empty_namespace_returns_everything() { + wp_set_secret( 'pluginone/key', 'value' ); + wp_set_secret( 'plugintwo/key', 'value' ); + + $this->assertCount( 2, wp_list_secrets( '' ) ); + } + + public function test_namespace_does_not_match_a_prefix_of_a_different_namespace() { + // 'plugin' must not match 'pluginone/key' -- only a full "namespace/" prefix. + wp_set_secret( 'pluginone/key', 'value' ); + + $this->assertSame( array(), wp_list_secrets( 'plugin' ) ); + } + + public function test_a_non_string_namespace_is_a_wp_error_not_an_exception() { + $this->setExpectedIncorrectUsage( '_wp_secrets_list' ); + + $result = wp_list_secrets( array( 'not', 'a', 'string' ) ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); + } + + /** + * 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() { + update_option( '_wp_secret_myplugin/corrupt', 'not a record at all', false ); + + $entries = wp_list_secrets(); + + $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() { + wp_set_secret( 'myplugin/site-only', 'value' ); + _wp_secrets_set( 'myplugin/network-only', 'value', true ); + + $names = wp_list_pluck( wp_list_secrets(), 'name' ); + + $this->assertSame( array( 'myplugin/site-only' ), $names ); + } +} diff --git a/tests/phpunit/tests/secrets/wpSecret.php b/tests/phpunit/tests/secrets/wpSecret.php new file mode 100644 index 0000000000000..e4572ded2404a --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecret.php @@ -0,0 +1,326 @@ +assertTrue( $reflection->isFinal() ); + } + + public function test_implements_json_serializable() { + $this->assertInstanceOf( JsonSerializable::class, $this->make_secret() ); + } + + public function test_reveal_returns_the_exact_plaintext() { + $secret = $this->make_secret(); + + $this->assertSame( self::PLAINTEXT, $secret->reveal() ); + } + + public function test_fingerprint_returns_the_exact_fingerprint() { + $secret = $this->make_secret(); + + $this->assertSame( self::FINGERPRINT, $secret->fingerprint() ); + } + + public function test_get_name_returns_the_exact_name() { + $secret = $this->make_secret(); + + $this->assertSame( self::NAME, $secret->get_name() ); + } + + public function test_constructor_rejects_a_non_string_value() { + $this->expectException( InvalidArgumentException::class ); + + new WP_Secret( self::NAME, 12345, self::FINGERPRINT ); + } + + public function test_constructor_rejects_an_empty_name() { + $this->expectException( InvalidArgumentException::class ); + + new WP_Secret( '', self::PLAINTEXT, self::FINGERPRINT ); + } + + public function test_constructor_rejects_an_empty_fingerprint() { + $this->expectException( InvalidArgumentException::class ); + + new WP_Secret( self::NAME, self::PLAINTEXT, '' ); + } + + /** + * Every representation short of reveal() must exclude the plaintext. + * + * @dataProvider data_masking_surfaces + */ + public function test_masking_surfaces_never_contain_the_plaintext( $callback ) { + $secret = $this->make_secret(); + + $output = $callback( $secret ); + + $this->assertStringNotContainsString( self::PLAINTEXT, $output ); + } + + public function data_masking_surfaces() { + return array( + '(string) cast' => array( + function ( $secret ) { + return (string) $secret; + }, + ), + 'string interpolation' => array( + function ( $secret ) { + return "{$secret}"; + }, + ), + 'json_encode' => array( + function ( $secret ) { + return json_encode( $secret ); + }, + ), + 'var_dump' => array( + function ( $secret ) { + ob_start(); + var_dump( $secret ); + + return ob_get_clean(); + }, + ), + 'print_r' => array( + function ( $secret ) { + return print_r( $secret, true ); + }, + ), + 'var_export' => array( + function ( $secret ) { + return var_export( $secret, true ); + }, + ), + 'error_log' => array( + function ( $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() { + $secret = $this->make_secret(); + + $this->assertSame( '[secret:' . self::NAME . ']', (string) $secret ); + } + + public function test_json_encode_yields_the_masked_placeholder() { + $secret = $this->make_secret(); + + // json_encode() escapes '/' by default; decode rather than compare raw JSON. + $this->assertSame( '[secret:' . self::NAME . ']', json_decode( json_encode( $secret ) ) ); + } + + public function test_var_dump_yields_the_masked_placeholder() { + $secret = $this->make_secret(); + + ob_start(); + var_dump( $secret ); + $output = ob_get_clean(); + + $this->assertStringContainsString( '[secret:' . self::NAME . ']', $output ); + } + + public function test_serialize_throws() { + $secret = $this->make_secret(); + + $this->expectException( LogicException::class ); + + serialize( $secret ); + } + + public function test_clone_throws() { + $secret = $this->make_secret(); + + $this->expectException( LogicException::class ); + + 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 + */ + public function test_serialization_magic_methods_throw_directly( $method, $args ) { + $secret = $this->make_secret(); + $reflection = new ReflectionMethod( $secret, $method ); + $reflection->setAccessible( true ); + + $this->expectException( LogicException::class ); + + $reflection->invokeArgs( $secret, $args ); + } + + 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() { + $secret = $this->make_secret(); + $id = spl_object_id( $secret ); + + $vault_property = new ReflectionProperty( WP_Secret::class, 'vault' ); + $vault_property->setAccessible( true ); + + $this->assertArrayHasKey( $id, $vault_property->getValue() ); + + unset( $secret ); + + $this->assertArrayNotHasKey( $id, $vault_property->getValue() ); + } + + /** + * 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() { + $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() { + $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() { + $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() { + $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() { + $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() { + $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 ); + } + + public function test_withheld_requires_a_wp_error_reason() { + $this->expectException( InvalidArgumentException::class ); + + WP_Secret::withheld( 'myplugin/signing-key', 'abc123', 'not an error' ); + } + + /** + * 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() { + $secret = new WP_Secret( 'myplugin/api-key', 'sk_live_value', 'fingerprint' ); + + $this->assertSame( 'sk_live_value', $secret->reveal() ); + } +} diff --git a/tests/phpunit/tests/secrets/wpSecretVersion.php b/tests/phpunit/tests/secrets/wpSecretVersion.php new file mode 100644 index 0000000000000..620c1de88042c --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecretVersion.php @@ -0,0 +1,30 @@ +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() { + $reflection = new ReflectionClass( WP_Secret_Version::class ); + + $this->assertTrue( $reflection->isFinal() ); + } + + public function test_class_declares_no_constructor() { + $reflection = new ReflectionClass( WP_Secret_Version::class ); + + $this->assertNull( $reflection->getConstructor() ); + } + + public function test_class_declares_exactly_two_constants() { + $reflection = new ReflectionClass( WP_Secret_Version::class ); + + $this->assertCount( 2, $reflection->getConstants() ); + } +} diff --git a/tests/phpunit/tests/secrets/wpSecretsCipher.php b/tests/phpunit/tests/secrets/wpSecretsCipher.php new file mode 100644 index 0000000000000..0339956b4cc1d --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecretsCipher.php @@ -0,0 +1,342 @@ +setAccessible( true ); + + return $reflection->invokeArgs( $instance, $args ); + } + + private function master_key( $byte = 0x11 ) { + return str_repeat( chr( $byte ), 32 ); + } + + public function test_round_trips_a_value() { + $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() { + $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() { + $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() { + $cipher = new WP_Secrets_Cipher(); + + $result = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, array() ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() ); + } + + /** + * @dataProvider data_bad_master_keys + */ + public function test_encrypt_rejects_a_bad_master_key( $bad_key ) { + $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() ); + } + + public function data_bad_master_keys() { + return array( + 'too short' => array( str_repeat( 'a', 31 ) ), + 'too long' => array( str_repeat( 'a', 33 ) ), + 'empty' => array( '' ), + 'not a string' => array( 12345 ), + ); + } + + public function test_encrypt_propagates_an_invalid_name() { + $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() { + $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' ); + + $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() { + $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() { + $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' ); + + $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 + */ + public function test_decrypt_fails_when_context_does_not_match( $scope, $site_id, $name, $slot ) { + $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() ); + } + + 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() { + $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 + */ + public function test_decrypt_fails_when_a_field_is_tampered( $field ) { + $cipher = new WP_Secrets_Cipher(); + $record = $cipher->encrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'value' ); + + $raw = base64_decode( $record[ $field ], true ); + $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 ); + } + + 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 + */ + public function test_decrypt_rejects_a_malformed_record( $record ) { + $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() ); + } + + 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() { + $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->assertNotSame( $a['dk'], $b['dk'] ); + $this->assertNotSame( $a['ct'], $b['ct'] ); + } + + public function test_nonce_uniqueness_across_many_writes() { + $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' ); + $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() { + $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() { + $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() { + $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() { + $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() { + $cipher = new WP_Secrets_Cipher(); + + $result = $cipher->fingerprint( $this->master_key(), array() ); + + $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() { + $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() { + $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() { + $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 ) ); + } +} diff --git a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php new file mode 100644 index 0000000000000..5e9973ced42e8 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php @@ -0,0 +1,313 @@ +setAccessible( true ); + + return $reflection->invokeArgs( $instance, $args ); + } + + public function test_implements_the_keyring_interface() { + $this->assertInstanceOf( WP_Secrets_Keyring::class, new WP_Secrets_Config_Key_Provider() ); + } + + public function test_wrap_then_unwrap_round_trips() { + $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_a_non_string() { + $provider = new WP_Secrets_Config_Key_Provider(); + + $result = $provider->wrap( 12345 ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() ); + } + + public function test_wrap_rejects_an_empty_string() { + $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() { + $provider = new WP_Secrets_Config_Key_Provider(); + $material = random_bytes( 32 ); + + $this->assertNotSame( $provider->wrap( $material ), $provider->wrap( $material ) ); + } + + public function test_unwrap_rejects_a_non_string() { + $provider = new WP_Secrets_Config_Key_Provider(); + + $result = $provider->unwrap( array() ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + } + + public function test_unwrap_rejects_garbage_base64() { + $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() { + $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() { + $provider = new WP_Secrets_Config_Key_Provider(); + $wrapped = $provider->wrap( random_bytes( 32 ) ); + + $raw = base64_decode( $wrapped, true ); + $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() { + 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 + */ + public function test_is_canonical_base64_32( $value, $expected ) { + $provider = new WP_Secrets_Config_Key_Provider(); + + $this->assertSame( $expected, $this->invoke_private( $provider, 'is_canonical_base64_32', array( $value ) ) ); + } + + public function data_canonical_base64_32() { + 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 + */ + public function test_are_usable_salt_values( $key, $salt, $expected ) { + $provider = new WP_Secrets_Config_Key_Provider(); + + $this->assertSame( $expected, $this->invoke_private( $provider, 'are_usable_salt_values', array( $key, $salt ) ) ); + } + + public function data_usable_salt_values() { + 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() { + $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() { + define( 'WP_SECRETS_KEY', 'not-base64-32!!' ); + + $provider = new WP_Secrets_Config_Key_Provider(); + $key = $this->invoke_private( $provider, 'get_site_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() { + $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() { + 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() { + 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() { + 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() { + 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() { + 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() { + $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() { + 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->assertWPError( $new_provider->unwrap( $wrapped_under_old ) ); + $this->assertIsString( $old_provider->unwrap( $wrapped_under_old ) ); + } +} diff --git a/tests/phpunit/tests/secrets/wpSecretsKeyManager.php b/tests/phpunit/tests/secrets/wpSecretsKeyManager.php new file mode 100644 index 0000000000000..0a763ed22812d --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecretsKeyManager.php @@ -0,0 +1,433 @@ +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() { + $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() { + $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() { + $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() { + $manager = new WP_Secrets_Key_Manager(); + $root_key = $manager->get_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() { + $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() { + $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() { + $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() { + $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() { + $manager = new WP_Secrets_Key_Manager(); + + $this->setExpectedIncorrectUsage( 'WP_Secrets_Key_Manager::get_master_key' ); + + $result = $manager->get_master_key( 'bogus' ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); + } + + /** + * @dataProvider data_invalid_site_ids + */ + public function test_get_master_key_reports_an_invalid_site_id_as_a_wp_error( $site_id ) { + $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() ); + } + + public function data_invalid_site_ids() { + return array( + 'zero' => array( 0 ), + 'negative' => array( -1 ), + 'string' => array( 'five' ), + ); + } + + /** + * 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() { + 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() { + 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() { + 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->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() { + 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->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() { + 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() { + $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() { + $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 { + public function wrap( $key_material ) { + return base64_encode( $key_material ); + } + public function unwrap( $wrapped ) { + return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, 'wrong key' ); + } + public function get_key_source() { + 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() { + 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() { + $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++ ) { + $this->assertSame( 32, strlen( $manager->get_master_key( 'site', $i + 1 ) ) ); + $this->assertSame( 32, strlen( $manager->get_master_key( 'network' ) ) ); + } + + $this->assertSame( 1, $mock->unwrap_call_count() ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_unwrap_is_called_once_across_many_secret_reads() { + $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() { + $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() { + $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() { + $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 ); + + $mock->configure_fail_unwrap( true ); + $this->assertWPError( $manager->get_root_key() ); + + $mock->configure_fail_unwrap( false ); + $this->assertSame( $root, $manager->get_root_key() ); + + $this->assertSame( $root, $manager->get_root_key() ); + $this->assertSame( 2, $mock->unwrap_call_count() ); + } + + public function test_generate_root_key_primes_the_cache() { + $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() { + $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(); + wp_secrets_memzero( $copy ); + + $this->assertSame( $root, $manager->get_root_key() ); + $this->assertSame( 1, $mock->unwrap_call_count() ); + } +} diff --git a/tests/phpunit/tests/secrets/wpSecretsMemzero.php b/tests/phpunit/tests/secrets/wpSecretsMemzero.php new file mode 100644 index 0000000000000..3bebe25d201c9 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecretsMemzero.php @@ -0,0 +1,43 @@ +assertNotSame( 'a plaintext value', $value ); + $this->assertSame( '', $value ); + } + + public function test_leaves_non_string_values_untouched() { + $value = 42; + + wp_secrets_memzero( $value ); + + $this->assertSame( 42, $value ); + } + + public function test_leaves_an_empty_string_untouched() { + $value = ''; + + wp_secrets_memzero( $value ); + + $this->assertSame( '', $value ); + } + + public function test_does_not_throw_when_the_value_is_shared() { + $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 ); + } +} diff --git a/tests/phpunit/tests/secrets/wpSecretsOptionStore.php b/tests/phpunit/tests/secrets/wpSecretsOptionStore.php new file mode 100644 index 0000000000000..f39ae6a10a045 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecretsOptionStore.php @@ -0,0 +1,160 @@ + 1, + 'current' => array( + 'dk' => 'x', + 'dk_nonce' => 'x', + 'ct' => 'x', + 'nonce' => 'x', + 'fingerprint' => 'x', + 'created' => 12345, + ), + ); + } + + public function test_implements_the_store_interface() { + $this->assertInstanceOf( WP_Secrets_Store::class, new WP_Secrets_Option_Store() ); + } + + public function test_get_returns_null_for_an_absent_secret() { + $store = new WP_Secrets_Option_Store(); + + $this->assertNull( $store->get( 'myplugin/absent' ) ); + } + + public function test_set_then_get_round_trips_a_record_site_scope() { + $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() { + $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() { + $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_set_rejects_a_non_array_record() { + $store = new WP_Secrets_Option_Store(); + + $result = $store->set( 'myplugin/key', 'not an array' ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() ); + } + + public function test_get_returns_an_error_when_the_stored_value_is_not_an_array() { + 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() { + $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() { + $store = new WP_Secrets_Option_Store(); + + $this->assertTrue( $store->delete( 'myplugin/never-existed' ) ); + } + + public function test_site_scope_records_are_not_autoloaded() { + 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() { + $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->assertContains( 'myplugin/one', $names ); + $this->assertContains( 'myplugin/two', $names ); + } + + public function test_list_names_does_not_include_unrelated_options() { + update_option( 'completely_unrelated_option', 'value' ); + + $store = new WP_Secrets_Option_Store(); + $store->set( 'myplugin/one', $this->sample_record() ); + + $this->assertNotContains( 'completely_unrelated_option', $store->list_names() ); + } + + public function test_list_names_does_not_include_network_scope_names() { + $store = new WP_Secrets_Option_Store(); + $store->set( 'myplugin/site-only', $this->sample_record(), false ); + $store->set( 'myplugin/network-only', $this->sample_record(), true ); + + $this->assertContains( 'myplugin/site-only', $store->list_names( false ) ); + $this->assertNotContains( 'myplugin/network-only', $store->list_names( false ) ); + $this->assertContains( 'myplugin/network-only', $store->list_names( true ) ); + $this->assertNotContains( 'myplugin/site-only', $store->list_names( true ) ); + } + + /** + * '_' 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() { + // 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->assertContains( 'myplugin/real', $names ); + $this->assertNotContains( 'decoy/name', $names ); + } +} diff --git a/tests/phpunit/tests/secrets/wpSecretsValidateName.php b/tests/phpunit/tests/secrets/wpSecretsValidateName.php new file mode 100644 index 0000000000000..736741c143c80 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecretsValidateName.php @@ -0,0 +1,96 @@ +assertTrue( wp_secrets_validate_name( $name ) ); + } + + public function data_valid_names() { + 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 + */ + public function test_rejects_invalid_names( $name ) { + $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 + */ + public function test_accepts_an_unnamespaced_name_but_reports_it( $name ) { + $this->setExpectedIncorrectUsage( 'wp_secrets_validate_name' ); + + $this->assertTrue( wp_secrets_validate_name( $name ) ); + } + + public function data_unnamespaced_names() { + 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() { + $result = wp_secrets_validate_name( 'Not_A_Valid_Name' ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); + } + + public function data_invalid_names() { + 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' ), + 'not a string: null' => array( null ), + 'not a string: int' => array( 12345 ), + 'not a string: array' => array( array( 'plugin/key' ) ), + 'not a string: bool' => array( true ), + 'one character over the max' => array( str_repeat( 'a', 85 ) . '/' . str_repeat( 'b', 87 ) ), + ); + } +} diff --git a/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php b/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php new file mode 100644 index 0000000000000..41d56fe73563d --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php @@ -0,0 +1,212 @@ +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() { + $this->assertNull( wp_get_secret( 'myplugin/never-set' ) ); + } + + public function test_get_defaults_to_the_current_version() { + wp_set_secret( 'myplugin/api-key', 'value' ); + + $this->assertSame( + wp_get_secret( 'myplugin/api-key' )->reveal(), + wp_get_secret( 'myplugin/api-key', WP_Secret_Version::CURRENT )->reveal() + ); + } + + public function test_get_previous_on_a_never_rotated_secret_is_null_not_error() { + 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() { + $this->setExpectedIncorrectUsage( '_wp_secrets_get' ); + + $result = wp_get_secret( 'myplugin/api-key', 'not-a-real-version' ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); + } + + public function test_set_rejects_an_invalid_name() { + $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() { + $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() { + $result = wp_set_secret( 'myplugin/api-key', array( 'not', 'a', 'string' ) ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() ); + } + + public function test_set_accepts_an_empty_string_value() { + $this->assertTrue( wp_set_secret( 'myplugin/api-key', '' ) ); + $this->assertSame( '', wp_get_secret( 'myplugin/api-key' )->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() { + update_option( '_wp_secret_myplugin/corrupt', 'not a record at all', false ); + + $this->assertTrue( wp_set_secret( 'myplugin/corrupt', 'a fresh value' ) ); + $this->assertSame( 'a fresh value', wp_get_secret( 'myplugin/corrupt' )->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() { + global $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() { + wp_set_secret( 'myplugin/api-key', 'must-survive-memzero' ); + + $this->assertSame( 'must-survive-memzero', wp_get_secret( 'myplugin/api-key' )->reveal() ); + } + + public function test_no_capability_check_is_applied() { + 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() { + $captured = null; + add_action( + 'wp_secret_changed', + function ( ...$args ) use ( &$captured ) { + $captured = $args; + }, + 10, + 6 + ); + + wp_set_secret( 'myplugin/api-key', 'value' ); + + $this->assertNotNull( $captured ); + list( $name, $action, $actor_id, $timestamp, $old_fingerprint, $new_fingerprint ) = $captured; + + $this->assertSame( 'myplugin/api-key', $name ); + $this->assertSame( 'created', $action ); + $this->assertSame( '', $old_fingerprint ); + $this->assertNotSame( '', $new_fingerprint ); + } + + public function test_change_hook_fires_on_overwrite_with_updated_action() { + wp_set_secret( 'myplugin/api-key', 'first-value' ); + $first_fingerprint = wp_get_secret( 'myplugin/api-key' )->fingerprint(); + + $captured = null; + add_action( + 'wp_secret_changed', + function ( ...$args ) use ( &$captured ) { + $captured = $args; + }, + 10, + 6 + ); + + wp_set_secret( 'myplugin/api-key', 'second-value' ); + + 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() { + $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() { + $captured_args = null; + add_action( + 'wp_secret_changed', + function ( ...$args ) use ( &$captured_args ) { + $captured_args = $args; + }, + 10, + 6 + ); + + wp_set_secret( 'myplugin/api-key', 'a-plaintext-value-that-must-not-leak' ); + + foreach ( $captured_args as $arg ) { + $this->assertIsScalar( $arg ); + $this->assertStringNotContainsString( 'a-plaintext-value-that-must-not-leak', (string) $arg ); + } + } +} diff --git a/tests/phpunit/tests/user/capabilities.php b/tests/phpunit/tests/user/capabilities.php index 34f46dfb1eec2..1f8717a96ef63 100644 --- a/tests/phpunit/tests/user/capabilities.php +++ b/tests/phpunit/tests/user/capabilities.php @@ -126,6 +126,7 @@ private function _getSingleSitePrimitiveCaps() { 'import' => array( 'administrator' ), 'list_users' => array( 'administrator' ), 'manage_options' => array( 'administrator' ), + 'manage_secrets' => array( 'administrator' ), 'promote_users' => array( 'administrator' ), 'remove_users' => array( 'administrator' ), 'switch_themes' => array( 'administrator' ), @@ -209,6 +210,7 @@ private function _getMultiSitePrimitiveCaps() { 'import' => array( 'administrator' ), 'list_users' => array( 'administrator' ), 'manage_options' => array( 'administrator' ), + 'manage_secrets' => array( 'administrator' ), 'promote_users' => array( 'administrator' ), 'remove_users' => array( 'administrator' ), 'switch_themes' => array( 'administrator' ), @@ -275,6 +277,7 @@ private function _getSingleSiteMetaCaps() { 'manage_network_plugins' => array(), 'manage_network_themes' => array(), 'manage_network_options' => array(), + 'manage_network_secrets' => array(), 'delete_site' => array(), 'upgrade_network' => array(), @@ -314,6 +317,7 @@ private function _getMultiSiteMetaCaps() { 'manage_network_plugins' => array(), 'manage_network_themes' => array(), 'manage_network_options' => array(), + 'manage_network_secrets' => array(), 'setup_network' => array(), 'upload_plugins' => array(), 'upload_themes' => array(), From 795c8e1fb85c9cf6c4235927eeafe21207396247 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 25 Sep 2026 14:59:26 -0700 Subject: [PATCH 04/12] Secrets API: call setAccessible() only below PHP 8.1 in tests PHP 8.5 deprecates ReflectionMethod::setAccessible() and ReflectionProperty::setAccessible(), which have had no effect since 8.1, so the four calls in the Secrets API tests errored on the PHP 8.5 jobs. They now use the same PHP_VERSION_ID < 80100 guard as the rest of the suite. --- tests/phpunit/tests/secrets/wpSecret.php | 8 ++++++-- tests/phpunit/tests/secrets/wpSecretsCipher.php | 4 +++- .../phpunit/tests/secrets/wpSecretsConfigKeyProvider.php | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/secrets/wpSecret.php b/tests/phpunit/tests/secrets/wpSecret.php index e4572ded2404a..963a7cd5c7502 100644 --- a/tests/phpunit/tests/secrets/wpSecret.php +++ b/tests/phpunit/tests/secrets/wpSecret.php @@ -178,7 +178,9 @@ public function test_clone_throws() { public function test_serialization_magic_methods_throw_directly( $method, $args ) { $secret = $this->make_secret(); $reflection = new ReflectionMethod( $secret, $method ); - $reflection->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $reflection->setAccessible( true ); + } $this->expectException( LogicException::class ); @@ -199,7 +201,9 @@ public function test_destructing_the_last_reference_removes_it_from_the_vault() $id = spl_object_id( $secret ); $vault_property = new ReflectionProperty( WP_Secret::class, 'vault' ); - $vault_property->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $vault_property->setAccessible( true ); + } $this->assertArrayHasKey( $id, $vault_property->getValue() ); diff --git a/tests/phpunit/tests/secrets/wpSecretsCipher.php b/tests/phpunit/tests/secrets/wpSecretsCipher.php index 0339956b4cc1d..d5848bc36b02d 100644 --- a/tests/phpunit/tests/secrets/wpSecretsCipher.php +++ b/tests/phpunit/tests/secrets/wpSecretsCipher.php @@ -10,7 +10,9 @@ class Tests_Secrets_WPSecretsCipher extends WP_UnitTestCase { private function invoke_private( $instance, $method, array $args = array() ) { $reflection = new ReflectionMethod( $instance, $method ); - $reflection->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $reflection->setAccessible( true ); + } return $reflection->invokeArgs( $instance, $args ); } diff --git a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php index 5e9973ced42e8..6fc137edb149a 100644 --- a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php +++ b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php @@ -15,7 +15,9 @@ class Tests_Secrets_WPSecretsConfigKeyProvider extends WP_UnitTestCase { private function invoke_private( $instance, $method, array $args = array() ) { $reflection = new ReflectionMethod( $instance, $method ); - $reflection->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $reflection->setAccessible( true ); + } return $reflection->invokeArgs( $instance, $args ); } From 0707590cf74ba929664646436c005648b09b7e75 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Mon, 5 Oct 2026 16:59:26 -0700 Subject: [PATCH 05/12] Secrets API: move the classes and interfaces into wp-includes/secrets/ Follows the layout abilities-api/ and ai-client/ use: the classes and interfaces sit in their own folder, and the functions file stays at wp-includes/secrets.php. The 13 files are renamed without edits, and wp-settings.php requires them from the new paths in the same order. See #66187. --- .../{ => secrets}/class-wp-secret-version.php | 0 .../{ => secrets}/class-wp-secret.php | 0 .../class-wp-secrets-broken-keyring.php | 0 .../class-wp-secrets-broken-provider.php | 0 .../class-wp-secrets-broken-store.php | 0 .../{ => secrets}/class-wp-secrets-cipher.php | 0 .../class-wp-secrets-config-key-provider.php | 0 .../class-wp-secrets-key-manager.php | 0 .../class-wp-secrets-libsodium-provider.php | 0 .../class-wp-secrets-option-store.php | 0 .../interface-wp-secrets-keyring.php | 0 .../interface-wp-secrets-provider.php | 0 .../interface-wp-secrets-store.php | 0 src/wp-settings.php | 26 +++++++++---------- 14 files changed, 13 insertions(+), 13 deletions(-) rename src/wp-includes/{ => secrets}/class-wp-secret-version.php (100%) rename src/wp-includes/{ => secrets}/class-wp-secret.php (100%) rename src/wp-includes/{ => secrets}/class-wp-secrets-broken-keyring.php (100%) rename src/wp-includes/{ => secrets}/class-wp-secrets-broken-provider.php (100%) rename src/wp-includes/{ => secrets}/class-wp-secrets-broken-store.php (100%) rename src/wp-includes/{ => secrets}/class-wp-secrets-cipher.php (100%) rename src/wp-includes/{ => secrets}/class-wp-secrets-config-key-provider.php (100%) rename src/wp-includes/{ => secrets}/class-wp-secrets-key-manager.php (100%) rename src/wp-includes/{ => secrets}/class-wp-secrets-libsodium-provider.php (100%) rename src/wp-includes/{ => secrets}/class-wp-secrets-option-store.php (100%) rename src/wp-includes/{ => secrets}/interface-wp-secrets-keyring.php (100%) rename src/wp-includes/{ => secrets}/interface-wp-secrets-provider.php (100%) rename src/wp-includes/{ => secrets}/interface-wp-secrets-store.php (100%) diff --git a/src/wp-includes/class-wp-secret-version.php b/src/wp-includes/secrets/class-wp-secret-version.php similarity index 100% rename from src/wp-includes/class-wp-secret-version.php rename to src/wp-includes/secrets/class-wp-secret-version.php diff --git a/src/wp-includes/class-wp-secret.php b/src/wp-includes/secrets/class-wp-secret.php similarity index 100% rename from src/wp-includes/class-wp-secret.php rename to src/wp-includes/secrets/class-wp-secret.php diff --git a/src/wp-includes/class-wp-secrets-broken-keyring.php b/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php similarity index 100% rename from src/wp-includes/class-wp-secrets-broken-keyring.php rename to src/wp-includes/secrets/class-wp-secrets-broken-keyring.php diff --git a/src/wp-includes/class-wp-secrets-broken-provider.php b/src/wp-includes/secrets/class-wp-secrets-broken-provider.php similarity index 100% rename from src/wp-includes/class-wp-secrets-broken-provider.php rename to src/wp-includes/secrets/class-wp-secrets-broken-provider.php diff --git a/src/wp-includes/class-wp-secrets-broken-store.php b/src/wp-includes/secrets/class-wp-secrets-broken-store.php similarity index 100% rename from src/wp-includes/class-wp-secrets-broken-store.php rename to src/wp-includes/secrets/class-wp-secrets-broken-store.php diff --git a/src/wp-includes/class-wp-secrets-cipher.php b/src/wp-includes/secrets/class-wp-secrets-cipher.php similarity index 100% rename from src/wp-includes/class-wp-secrets-cipher.php rename to src/wp-includes/secrets/class-wp-secrets-cipher.php diff --git a/src/wp-includes/class-wp-secrets-config-key-provider.php b/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php similarity index 100% rename from src/wp-includes/class-wp-secrets-config-key-provider.php rename to src/wp-includes/secrets/class-wp-secrets-config-key-provider.php diff --git a/src/wp-includes/class-wp-secrets-key-manager.php b/src/wp-includes/secrets/class-wp-secrets-key-manager.php similarity index 100% rename from src/wp-includes/class-wp-secrets-key-manager.php rename to src/wp-includes/secrets/class-wp-secrets-key-manager.php diff --git a/src/wp-includes/class-wp-secrets-libsodium-provider.php b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php similarity index 100% rename from src/wp-includes/class-wp-secrets-libsodium-provider.php rename to src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php diff --git a/src/wp-includes/class-wp-secrets-option-store.php b/src/wp-includes/secrets/class-wp-secrets-option-store.php similarity index 100% rename from src/wp-includes/class-wp-secrets-option-store.php rename to src/wp-includes/secrets/class-wp-secrets-option-store.php diff --git a/src/wp-includes/interface-wp-secrets-keyring.php b/src/wp-includes/secrets/interface-wp-secrets-keyring.php similarity index 100% rename from src/wp-includes/interface-wp-secrets-keyring.php rename to src/wp-includes/secrets/interface-wp-secrets-keyring.php diff --git a/src/wp-includes/interface-wp-secrets-provider.php b/src/wp-includes/secrets/interface-wp-secrets-provider.php similarity index 100% rename from src/wp-includes/interface-wp-secrets-provider.php rename to src/wp-includes/secrets/interface-wp-secrets-provider.php diff --git a/src/wp-includes/interface-wp-secrets-store.php b/src/wp-includes/secrets/interface-wp-secrets-store.php similarity index 100% rename from src/wp-includes/interface-wp-secrets-store.php rename to src/wp-includes/secrets/interface-wp-secrets-store.php diff --git a/src/wp-settings.php b/src/wp-settings.php index 6a6bca84a39d9..ab98a6083a7ca 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -309,19 +309,19 @@ require ABSPATH . WPINC . '/admin-bar.php'; require ABSPATH . WPINC . '/class-wp-application-passwords.php'; require ABSPATH . WPINC . '/secrets.php'; -require ABSPATH . WPINC . '/class-wp-secret-version.php'; -require ABSPATH . WPINC . '/class-wp-secret.php'; -require ABSPATH . WPINC . '/interface-wp-secrets-provider.php'; -require ABSPATH . WPINC . '/interface-wp-secrets-keyring.php'; -require ABSPATH . WPINC . '/interface-wp-secrets-store.php'; -require ABSPATH . WPINC . '/class-wp-secrets-config-key-provider.php'; -require ABSPATH . WPINC . '/class-wp-secrets-broken-keyring.php'; -require ABSPATH . WPINC . '/class-wp-secrets-cipher.php'; -require ABSPATH . WPINC . '/class-wp-secrets-key-manager.php'; -require ABSPATH . WPINC . '/class-wp-secrets-option-store.php'; -require ABSPATH . WPINC . '/class-wp-secrets-broken-store.php'; -require ABSPATH . WPINC . '/class-wp-secrets-libsodium-provider.php'; -require ABSPATH . WPINC . '/class-wp-secrets-broken-provider.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'; require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php'; From ada26072bcb5cb13dcb18c778247f2051776cc7e Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Wed, 7 Oct 2026 12:18:46 -0700 Subject: [PATCH 06/12] Build/Test Tools: treat LOGGED_IN_KEY and LOGGED_IN_SALT as dynamic in PHPStan PHPStan reads both constants from wp-config-sample.php, where they hold the placeholder phrase, and so treats any comparison against that phrase as always true. Their values differ on every install, like AUTH_SALT, which is already listed. The Secrets API refuses to derive a key from the placeholder salts, and its tests and the test bootstrap check for them. See #66187. --- tests/phpstan/base.neon | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 4cf457f07e0f7..1283841b02083 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -141,6 +141,8 @@ parameters: - HEADER_IMAGE_HEIGHT - HEADER_IMAGE_WIDTH - HEADER_TEXTCOLOR + - LOGGED_IN_KEY + - LOGGED_IN_SALT - MEDIA_TRASH - MULTISITE - NO_HEADER_TEXT From b830afbd382c66056ff491cb54edd280fff41b48 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Wed, 7 Oct 2026 12:18:47 -0700 Subject: [PATCH 07/12] Secrets API: resolve PHPStan level 10 findings Addresses the PHPStan level 10 report on the pull request: 85 findings in src/ and 400 in the tests. In src/, most fixes are docblocks that say what an array holds, with @phpstan-return shapes where a list entry or a record slot has fixed keys. The code changes: - Decrypt results are checked with is_string() rather than against false, since sodium_compat's signatures allow more than string|false. - The cached root key is only served once it is known to be set. - The config keyring decodes a canonical base64 key once, instead of validating it and then decoding it again. - Demoting a slot keeps `created` only when it is an integer and stores `needs_rotation` as a boolean. - Listing skips a stored option name that is not a string. - The request-cached store, key manager, and provider are narrowed with instanceof rather than compared against null. - wp_secrets_memzero(), wp_load_secrets_dropin(), populate_roles_720(), and upgrade_720() declare a void return. - The two "documented in" references for wp_secret_changed name the file that documents the hook. Docblocks no longer put a blank line between @param and @return. In the tests, values are narrowed with assertions before use, helpers and data providers are typed, and test methods declare a void return. A test that passes a wrong type on purpose, or pins a constant or an interface, carries an `@phpstan-ignore (reason)` comment. The error-code test is now one case per constant. This is the feature plugin's src/ and tests at 0.2.2. See #66187. --- src/wp-admin/includes/schema.php | 2 +- src/wp-admin/includes/secrets-site-health.php | 23 ++-- src/wp-admin/includes/upgrade.php | 2 +- src/wp-includes/load.php | 2 +- src/wp-includes/secrets.php | 31 ++---- src/wp-includes/secrets/class-wp-secret.php | 10 +- .../class-wp-secrets-broken-keyring.php | 2 - .../class-wp-secrets-broken-provider.php | 5 - .../secrets/class-wp-secrets-broken-store.php | 10 +- .../secrets/class-wp-secrets-cipher.php | 16 +-- .../class-wp-secrets-config-key-provider.php | 35 ++++-- .../secrets/class-wp-secrets-key-manager.php | 9 +- .../class-wp-secrets-libsodium-provider.php | 54 +++++---- .../secrets/class-wp-secrets-option-store.php | 21 ++-- .../secrets/interface-wp-secrets-keyring.php | 2 - .../secrets/interface-wp-secrets-provider.php | 7 +- .../secrets/interface-wp-secrets-store.php | 14 +-- .../includes/secrets/class-mock-keyring.php | 29 ++++- .../includes/secrets/class-mock-store.php | 28 ++++- .../class-wp-secrets-keyring-conformance.php | 16 ++- .../class-wp-secrets-provider-conformance.php | 59 ++++++---- .../secrets/trait-wp-secrets-assertions.php | 6 +- .../tests/secrets/brokenDropinFallbacks.php | 26 ++--- tests/phpunit/tests/secrets/capabilities.php | 53 ++++++--- tests/phpunit/tests/secrets/errorCodes.php | 35 ++++-- .../phpunit/tests/secrets/extensionPoints.php | 42 ++++--- tests/phpunit/tests/secrets/networkScope.php | 43 ++++--- tests/phpunit/tests/secrets/provider.php | 70 +++++++++--- .../secrets/readonlyProviderConformance.php | 45 ++++++++ tests/phpunit/tests/secrets/siteHealth.php | 92 ++++++++++----- .../tests/secrets/threeStateContract.php | 44 +++++--- tests/phpunit/tests/secrets/versionSlots.php | 94 +++++++++++----- .../phpunit/tests/secrets/wpDeleteSecret.php | 22 ++-- .../tests/secrets/wpImportOptionAsSecret.php | 40 +++++-- tests/phpunit/tests/secrets/wpListSecrets.php | 74 +++++++----- tests/phpunit/tests/secrets/wpSecret.php | 98 ++++++++++------ .../phpunit/tests/secrets/wpSecretVersion.php | 8 +- .../phpunit/tests/secrets/wpSecretsCipher.php | 105 +++++++++++++----- .../secrets/wpSecretsConfigKeyProvider.php | 84 +++++++++----- .../tests/secrets/wpSecretsKeyManager.php | 100 +++++++++++------ .../tests/secrets/wpSecretsMemzero.php | 14 +-- .../tests/secrets/wpSecretsOptionStore.php | 62 +++++++---- .../tests/secrets/wpSecretsValidateName.php | 31 ++++-- .../secrets/wpSetSecretAndWpGetSecret.php | 73 +++++++----- 44 files changed, 1087 insertions(+), 551 deletions(-) diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 0cca17dffe3ef..5dc0b4325228f 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -974,7 +974,7 @@ function populate_roles_300() { * * @since 7.2.0 */ -function populate_roles_720() { +function populate_roles_720(): void { $role = get_role( 'administrator' ); if ( ! empty( $role ) ) { diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index d4883032dbde1..f41692dfbd155 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -16,9 +16,8 @@ * * @since 7.2.0 * - * @param array $tests Existing Site Health tests. - * - * @return array + * @param array>> $tests Existing Site Health tests. + * @return array>> */ function wp_secrets_site_health_tests( $tests ) { $tests['direct']['secrets_api_key_source'] = array( @@ -50,8 +49,9 @@ function wp_secrets_site_health_tests( $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

    elements. - * * @return array + * + * @phpstan-return array{test: string, label: string, status: string, badge: array{label: string, color: string}, description: string} */ function wp_secrets_site_health_result( $test, $label, $status, $description ) { $colors = array( @@ -78,6 +78,8 @@ function wp_secrets_site_health_result( $test, $label, $status, $description ) { * @since 7.2.0 * * @return array + * + * @phpstan-return array{test: string, label: string, status: string, badge: array{label: string, color: string}, description: string} */ function wp_secrets_site_health_test_key_source() { $key_source = _wp_secrets_get_key_manager()->get_keyring()->get_key_source(); @@ -135,6 +137,8 @@ function wp_secrets_site_health_test_key_source() { * @since 7.2.0 * * @return array + * + * @phpstan-return array{test: string, label: string, status: string, badge: array{label: string, color: string}, description: string} */ function wp_secrets_site_health_test_undecryptable() { $broken = wp_secrets_site_health_find_undecryptable( false ); @@ -185,8 +189,9 @@ function wp_secrets_site_health_test_undecryptable() { * @since 7.2.0 * * @param bool $network Whether to check network-scope secrets. - * * @return array List of array( 'name' => ..., 'fingerprint' => ... ). + * + * @phpstan-return list */ function wp_secrets_site_health_find_undecryptable( $network ) { $entries = $network ? wp_list_network_secrets() : wp_list_secrets(); @@ -217,6 +222,8 @@ function wp_secrets_site_health_find_undecryptable( $network ) { * @since 7.2.0 * * @return array + * + * @phpstan-return array{test: string, label: string, status: string, badge: array{label: string, color: string}, description: string} */ function wp_secrets_site_health_test_needs_rotation() { $count = wp_secrets_site_health_count_needing_rotation( false ); @@ -262,7 +269,6 @@ function wp_secrets_site_health_test_needs_rotation() { * @since 7.2.0 * * @param bool $network Whether to check network-scope secrets. - * * @return int */ function wp_secrets_site_health_count_needing_rotation( $network ) { @@ -291,9 +297,8 @@ function wp_secrets_site_health_count_needing_rotation( $network ) { * * @since 7.2.0 * - * @param array $info Existing debug information sections. - * - * @return array + * @param array> $info Existing debug information sections. + * @return array> */ function wp_secrets_site_health_debug_info( $info ) { $key_manager = _wp_secrets_get_key_manager(); diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 40c695e135b05..7149e0f4cd901 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -2512,7 +2512,7 @@ function upgrade_700() { * * @global int $wp_current_db_version The old (current) database version. */ -function upgrade_720() { +function upgrade_720(): void { global $wp_current_db_version; // Grant the Secrets API's site-level capability to administrators. diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 1b8d25a941338..a0b7cf2c38aef 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -845,7 +845,7 @@ function wp_using_ext_object_cache( $using = null ) { * @since 7.2.0 * @access private */ -function wp_load_secrets_dropin() { +function wp_load_secrets_dropin(): void { static $loaded = false; if ( $loaded ) { diff --git a/src/wp-includes/secrets.php b/src/wp-includes/secrets.php index 6030acc23c4e6..dfe10e6dd9e25 100644 --- a/src/wp-includes/secrets.php +++ b/src/wp-includes/secrets.php @@ -166,7 +166,7 @@ * * @param string $value The value to clear, by reference. */ -function wp_secrets_memzero( &$value ) { +function wp_secrets_memzero( &$value ): void { if ( ! is_string( $value ) ) { return; } @@ -219,7 +219,6 @@ function wp_secrets_memzero( &$value ) { * @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. */ @@ -321,7 +320,7 @@ function wp_secrets_validate_name( $name ) { function _wp_secrets_get_store() { static $store = null; - if ( null !== $store ) { + if ( $store instanceof WP_Secrets_Store ) { return $store; } @@ -357,7 +356,7 @@ function _wp_secrets_get_store() { function _wp_secrets_get_key_manager() { static $key_manager = null; - if ( null !== $key_manager ) { + if ( $key_manager instanceof WP_Secrets_Key_Manager ) { return $key_manager; } @@ -423,7 +422,6 @@ function wp_using_secrets_dropin() { * * @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( $name, $value ) { @@ -448,7 +446,6 @@ function wp_set_secret( $name, $value ) { * * @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( $option, $name ) { @@ -491,7 +488,6 @@ function wp_import_option_as_secret( $option, $name ) { * * @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 */ function wp_get_secret( $name, $version = WP_Secret_Version::CURRENT ) { @@ -504,7 +500,6 @@ function wp_get_secret( $name, $version = WP_Secret_Version::CURRENT ) { * @since 7.2.0 * * @param string $name The secret's namespaced name. - * * @return true|WP_Error */ function wp_delete_secret( $name ) { @@ -523,7 +518,6 @@ function wp_delete_secret( $name ) { * @since 7.2.0 * * @param string $name The secret's namespaced name. - * * @return true|WP_Error */ function wp_retire_secret_version( $name ) { @@ -541,10 +535,11 @@ function wp_retire_secret_version( $name ) { * * @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|WP_Error */ function wp_list_secrets( $namespace = '' ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.namespaceFound -- $namespace is the parameter name the proposal specifies. return _wp_secrets_list( $namespace, false ); @@ -562,7 +557,6 @@ function wp_list_secrets( $namespace = '' ) { // phpcs:ignore Universal.NamingCo * * @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( $name, $value ) { @@ -576,7 +570,6 @@ function wp_set_network_secret( $name, $value ) { * * @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 */ function wp_get_network_secret( $name, $version = WP_Secret_Version::CURRENT ) { @@ -589,7 +582,6 @@ function wp_get_network_secret( $name, $version = WP_Secret_Version::CURRENT ) { * @since 7.2.0 * * @param string $name The secret's namespaced name. - * * @return true|WP_Error */ function wp_delete_network_secret( $name ) { @@ -602,7 +594,6 @@ function wp_delete_network_secret( $name ) { * @since 7.2.0 * * @param string $name The secret's namespaced name. - * * @return true|WP_Error */ function wp_retire_network_secret_version( $name ) { @@ -616,8 +607,9 @@ function wp_retire_network_secret_version( $name ) { * * @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|WP_Error */ function wp_list_network_secrets( $namespace = '' ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.namespaceFound -- matches wp_list_secrets()'s own signature. return _wp_secrets_list( $namespace, true ); @@ -651,7 +643,7 @@ function wp_list_network_secrets( $namespace = '' ) { // phpcs:ignore Universal. function _wp_secrets_get_provider() { static $provider = null; - if ( null !== $provider ) { + if ( $provider instanceof WP_Secrets_Provider ) { return $provider; } @@ -727,7 +719,6 @@ function wp_secrets_provider_label() { * @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( $name, $value, $network, $needs_rotation = false, $action_override = null ) { @@ -742,7 +733,6 @@ function _wp_secrets_set( $name, $value, $network, $needs_rotation = false, $act * @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 */ function _wp_secrets_get( $name, $version, $network ) { @@ -775,7 +765,6 @@ function _wp_secrets_get( $name, $version, $network ) { * * @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( $name, $network ) { @@ -789,7 +778,6 @@ function _wp_secrets_delete( $name, $network ) { * * @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( $name, $network ) { @@ -803,8 +791,9 @@ function _wp_secrets_retire( $name, $network ) { * * @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|WP_Error */ function _wp_secrets_list( $name_prefix, $network ) { // See _wp_secrets_get() for why this is checked here and not per-provider. diff --git a/src/wp-includes/secrets/class-wp-secret.php b/src/wp-includes/secrets/class-wp-secret.php index 0d389e973f49e..1aef83ba609d5 100644 --- a/src/wp-includes/secrets/class-wp-secret.php +++ b/src/wp-includes/secrets/class-wp-secret.php @@ -205,6 +205,8 @@ public function get_name() { * @since 7.2.0 * * @return string + * + * @phpstan-return non-falsy-string */ private function mask() { return '[secret:' . $this->name . ']'; @@ -218,6 +220,8 @@ private function mask() { * @since 7.2.0 * * @return string + * + * @phpstan-return non-falsy-string */ public function __toString() { return $this->mask(); @@ -234,7 +238,7 @@ public function __toString() { * * @since 7.2.0 * - * @return array + * @return array */ public function __debugInfo(): array { return array( 'value' => $this->mask() ); @@ -246,6 +250,8 @@ public function __debugInfo(): array { * @since 7.2.0 * * @return string + * + * @phpstan-return non-falsy-string */ #[\ReturnTypeWillChange] public function jsonSerialize() { @@ -299,7 +305,7 @@ public function __serialize() { * * @since 7.2.0 * - * @param array $data Ignored. + * @param array $data Ignored. * * @throws LogicException Always. * diff --git a/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php b/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php index 60c7b8e6cd6f7..abcab9980e71e 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php @@ -28,7 +28,6 @@ final class WP_Secrets_Broken_Keyring implements WP_Secrets_Keyring { * @since 7.2.0 * * @param string $key_material Ignored. - * * @return WP_Error */ public function wrap( $key_material ) { @@ -41,7 +40,6 @@ public function wrap( $key_material ) { * @since 7.2.0 * * @param string $wrapped Ignored. - * * @return WP_Error */ public function unwrap( $wrapped ) { diff --git a/src/wp-includes/secrets/class-wp-secrets-broken-provider.php b/src/wp-includes/secrets/class-wp-secrets-broken-provider.php index 5c2725d834934..48ebf63823f8d 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-provider.php @@ -46,7 +46,6 @@ private function error() { * @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 */ public function get( $name, $version, $network = false ) { @@ -63,7 +62,6 @@ public function get( $name, $version, $network = false ) { * @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( $name, $value, $network = false, $needs_rotation = false, $action = null ) { @@ -77,7 +75,6 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ * * @param string $name The secret's name. * @param bool $network Whether this is a network-scope secret. - * * @return WP_Error */ public function delete( $name, $network = false ) { @@ -91,7 +88,6 @@ public function delete( $name, $network = false ) { * * @param string $name The secret's name. * @param bool $network Whether this is a network-scope secret. - * * @return WP_Error */ public function retire_previous( $name, $network = false ) { @@ -105,7 +101,6 @@ public function retire_previous( $name, $network = false ) { * * @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( $name_prefix = '', $network = false ) { diff --git a/src/wp-includes/secrets/class-wp-secrets-broken-store.php b/src/wp-includes/secrets/class-wp-secrets-broken-store.php index cbb5842dfb803..4e06312a7c556 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-store.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-store.php @@ -30,7 +30,6 @@ final class WP_Secrets_Broken_Store implements WP_Secrets_Store { * * @param string $name Ignored. * @param bool $network Ignored. - * * @return WP_Error */ public function get( $name, $network = false ) { @@ -42,10 +41,9 @@ public function get( $name, $network = false ) { * * @since 7.2.0 * - * @param string $name Ignored. - * @param array $record Ignored. - * @param bool $network Ignored. - * + * @param string $name Ignored. + * @param array $record Ignored. + * @param bool $network Ignored. * @return WP_Error */ public function set( $name, $record, $network = false ) { @@ -59,7 +57,6 @@ public function set( $name, $record, $network = false ) { * * @param string $name Ignored. * @param bool $network Ignored. - * * @return WP_Error */ public function delete( $name, $network = false ) { @@ -72,7 +69,6 @@ public function delete( $name, $network = false ) { * @since 7.2.0 * * @param bool $network Ignored. - * * @return WP_Error */ public function list_names( $network = false ) { diff --git a/src/wp-includes/secrets/class-wp-secrets-cipher.php b/src/wp-includes/secrets/class-wp-secrets-cipher.php index 1f7b3eb8c5744..8579c18f3ae38 100644 --- a/src/wp-includes/secrets/class-wp-secrets-cipher.php +++ b/src/wp-includes/secrets/class-wp-secrets-cipher.php @@ -62,9 +62,10 @@ final class WP_Secrets_Cipher { * @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 */ public function encrypt_value( $master_key, $scope, $site_id, $name, $slot, $plaintext ) { $check = $this->validate_common( $master_key, $scope, $site_id, $name, $slot ); @@ -134,7 +135,6 @@ public function encrypt_value( $master_key, $scope, $site_id, $name, $slot, $pla * @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. */ public function decrypt_value( $master_key, $scope, $site_id, $name, $slot, $record ) { @@ -164,7 +164,7 @@ public function decrypt_value( $master_key, $scope, $site_id, $name, $slot, $rec $master_key ); - if ( false === $data_key ) { + if ( ! is_string( $data_key ) ) { return new WP_Error( WP_SECRETS_ERROR_DECRYPTION_FAILED, __( "The secret's data key could not be decrypted.", 'default' ) @@ -180,7 +180,7 @@ public function decrypt_value( $master_key, $scope, $site_id, $name, $slot, $rec wp_secrets_memzero( $data_key ); - if ( false === $plaintext ) { + if ( ! is_string( $plaintext ) ) { return new WP_Error( WP_SECRETS_ERROR_DECRYPTION_FAILED, __( 'The secret value could not be decrypted.', 'default' ) @@ -204,7 +204,6 @@ public function decrypt_value( $master_key, $scope, $site_id, $name, $slot, $rec * * @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( $master_key, $plaintext ) { @@ -255,7 +254,6 @@ public function fingerprint( $master_key, $plaintext ) { * @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 */ private function build_aad( $purpose, $scope, $site_id, $name, $slot ) { @@ -281,7 +279,6 @@ private function build_aad( $purpose, $scope, $site_id, $name, $slot ) { * @param int $site_id Candidate site id. * @param string $name Candidate secret name. * @param string $slot Candidate slot. - * * @return true|WP_Error */ private function validate_common( $master_key, $scope, $site_id, $name, $slot ) { @@ -322,7 +319,6 @@ private function validate_common( $master_key, $scope, $site_id, $name, $slot ) * @since 7.2.0 * * @param string $message What the caller got wrong. - * * @return WP_Error */ private function invalid_argument( $message ) { @@ -337,7 +333,6 @@ private function invalid_argument( $message ) { * @since 7.2.0 * * @param mixed $master_key Candidate master key. - * * @return bool */ private function is_valid_master_key( $master_key ) { @@ -350,8 +345,7 @@ private function is_valid_master_key( $master_key ) { * @since 7.2.0 * * @param mixed $record Candidate slot array. - * - * @return array|WP_Error Decoded ('dk', 'dk_nonce', 'ct', 'nonce') on success. + * @return array|WP_Error Decoded ('dk', 'dk_nonce', 'ct', 'nonce') on success. */ private function decode_record_fields( $record ) { if ( ! is_array( $record ) ) { diff --git a/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php b/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php index a76794a6d041c..3647e94bc5f98 100644 --- a/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php @@ -82,7 +82,6 @@ public function __construct( $use_previous_key = false ) { * @since 7.2.0 * * @param string $key_material Raw key material to protect. - * * @return string|WP_Error */ public function wrap( $key_material ) { @@ -120,7 +119,6 @@ public function wrap( $key_material ) { * @since 7.2.0 * * @param string $wrapped An opaque value previously returned by wrap(). - * * @return string|WP_Error */ public function unwrap( $wrapped ) { @@ -161,7 +159,7 @@ public function unwrap( $wrapped ) { wp_secrets_memzero( $site_key ); - if ( false === $key_material ) { + 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.', 'default' ) @@ -212,8 +210,10 @@ private function get_site_key() { ); } - if ( $this->is_canonical_base64_32( $raw_constant ) ) { - return base64_decode( $raw_constant, true ); + $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 ); @@ -255,8 +255,10 @@ private function get_site_key() { * * @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 ) { foreach ( array( $logged_in_key, $logged_in_salt ) as $value ) { @@ -278,21 +280,32 @@ private function are_usable_salt_values( $logged_in_key, $logged_in_salt ) { * * @since 7.2.0 * - * @param string $value Candidate value. - * + * @param mixed $value Candidate value. * @return bool */ private function is_canonical_base64_32( $value ) { + 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 ) { if ( ! is_string( $value ) ) { - return false; + return null; } $decoded = base64_decode( $value, true ); if ( false === $decoded || SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES !== strlen( $decoded ) ) { - return false; + return null; } - return base64_encode( $decoded ) === $value; + return base64_encode( $decoded ) === $value ? $decoded : null; } } diff --git a/src/wp-includes/secrets/class-wp-secrets-key-manager.php b/src/wp-includes/secrets/class-wp-secrets-key-manager.php index db65b6e5f47b4..d8426f4a0ece8 100644 --- a/src/wp-includes/secrets/class-wp-secrets-key-manager.php +++ b/src/wp-includes/secrets/class-wp-secrets-key-manager.php @@ -138,7 +138,6 @@ public function get_keyring() { * @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() @@ -213,7 +212,7 @@ public function get_root_key() { ); } - if ( null !== $this->cached_wrapped && $wrapped === $this->cached_wrapped ) { + if ( null !== $this->cached_root_key && $wrapped === $this->cached_wrapped ) { return $this->cached_root_key; } @@ -238,7 +237,6 @@ public function get_root_key() { * * @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 ) { @@ -251,7 +249,7 @@ public function rotate_site_key( WP_Secrets_Keyring $old_keyring, WP_Secrets_Key ); } - if ( $old_keyring === $this->keyring && null !== $this->cached_wrapped && $wrapped === $this->cached_wrapped ) { + 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 ); @@ -306,7 +304,8 @@ public function rotate_site_key( WP_Secrets_Keyring $old_keyring, WP_Secrets_Key * * @since 7.2.0 * - * @return string|false The wrapped root key, or false if none exists anywhere. + * @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 ); diff --git a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php index d096c2bac498b..a1f4c5ce94ce6 100644 --- a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php @@ -117,7 +117,6 @@ public function is_writable() { * @since 7.2.0 * * @param mixed $record Candidate record. - * * @return true|WP_Error */ private function validate_record_shape( $record ) { @@ -167,9 +166,9 @@ private function validate_record_shape( $record ) { * @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|null|WP_Error The prior record, null if absent or unreadable, or - * WP_Error only if the store itself is unavailable. + * @return array|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( $store, $name, $network ) { $existing = $store->get( $name, $network ); @@ -191,15 +190,30 @@ private function read_prior_record( $store, $name, $network ) { * @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 ) { - if ( ! is_array( $record ) || ! isset( $record['current']['fingerprint'] ) || ! is_string( $record['current']['fingerprint'] ) ) { + 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 ) { + if ( ! is_array( $slot ) || ! isset( $slot['fingerprint'] ) || ! is_string( $slot['fingerprint'] ) ) { return ''; } - return $record['current']['fingerprint']; + return $slot['fingerprint']; } /** @@ -219,13 +233,14 @@ private function stored_fingerprint( $record ) { * @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 $current_slot The outgoing current slot, as stored. - * + * @param array $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 */ private function demote_slot( $cipher, $master_key, $scope, $site_id, $name, $current_slot ) { $plaintext = $cipher->decrypt_value( $master_key, $scope, $site_id, $name, WP_Secret_Version::CURRENT, $current_slot ); @@ -242,8 +257,8 @@ private function demote_slot( $cipher, $master_key, $scope, $site_id, $name, $cu return $demoted; } - $demoted['created'] = isset( $current_slot['created'] ) ? $current_slot['created'] : time(); - $demoted['needs_rotation'] = isset( $current_slot['needs_rotation'] ) ? $current_slot['needs_rotation'] : false; + $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; } @@ -269,7 +284,6 @@ private function demote_slot( $cipher, $master_key, $scope, $site_id, $name, $cu * 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( $name, $value, $network = false, $needs_rotation = false, $action = null ) { @@ -395,7 +409,6 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ * @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 */ public function get( $name, $version, $network = false ) { @@ -480,7 +493,6 @@ public function get( $name, $version, $network = false ) { * * @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( $name, $network = false ) { @@ -505,7 +517,7 @@ public function delete( $name, $network = false ) { } if ( is_array( $existing ) ) { - /** This action is documented in src/wp-includes/secrets.php */ + /** This action is documented in wp-includes/secrets/class-wp-secrets-libsodium-provider.php */ do_action( 'wp_secret_changed', $name, @@ -533,7 +545,6 @@ public function delete( $name, $network = false ) { * * @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( $name, $network = false ) { @@ -562,9 +573,7 @@ public function retire_previous( $name, $network = false ) { return true; } - $retired_fingerprint = isset( $record['previous']['fingerprint'] ) && is_string( $record['previous']['fingerprint'] ) - ? $record['previous']['fingerprint'] - : ''; + $retired_fingerprint = $this->slot_fingerprint( $record['previous'] ); unset( $record['previous'] ); @@ -574,7 +583,7 @@ public function retire_previous( $name, $network = false ) { return $result; } - /** This action is documented in src/wp-includes/secrets.php */ + /** This action is documented in wp-includes/secrets/class-wp-secrets-libsodium-provider.php */ do_action( 'wp_secret_changed', $name, @@ -612,10 +621,11 @@ public function retire_previous( $name, $network = false ) { * $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|WP_Error */ public function list_secrets( $name_prefix = '', $network = false ) { @@ -636,7 +646,7 @@ public function list_secrets( $name_prefix = '', $network = false ) { $record = $store->get( $name, $network ); - if ( ! is_wp_error( $record ) && null !== $record && true === $this->validate_record_shape( $record ) ) { + 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 ), diff --git a/src/wp-includes/secrets/class-wp-secrets-option-store.php b/src/wp-includes/secrets/class-wp-secrets-option-store.php index 843afd1a896d7..6f7051d992e5a 100644 --- a/src/wp-includes/secrets/class-wp-secrets-option-store.php +++ b/src/wp-includes/secrets/class-wp-secrets-option-store.php @@ -46,8 +46,7 @@ final class WP_Secrets_Option_Store implements WP_Secrets_Store { * * @param string $name The secret's namespaced name. * @param bool $network Whether this is a network-scope secret. - * - * @return array|null|WP_Error + * @return array|null|WP_Error */ public function get( $name, $network = false ) { $option_name = $this->option_name( $name, $network ); @@ -72,10 +71,9 @@ public function get( $name, $network = false ) { * * @since 7.2.0 * - * @param string $name The secret's namespaced name. - * @param array $record The record to store. - * @param bool $network Whether this is a network-scope secret. - * + * @param string $name The secret's namespaced name. + * @param array $record The record to store. + * @param bool $network Whether this is a network-scope secret. * @return true|WP_Error */ public function set( $name, $record, $network = false ) { @@ -123,7 +121,6 @@ public function set( $name, $record, $network = false ) { * * @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( $name, $network = false ) { @@ -153,9 +150,10 @@ public function delete( $name, $network = false ) { * * @since 7.2.0 * - * @param bool $network Whether to list network-scope secrets. + * @global wpdb $wpdb WordPress database abstraction object. * - * @return array|WP_Error + * @param bool $network Whether to list network-scope secrets. + * @return string[]|WP_Error */ public function list_names( $network = false ) { global $wpdb; @@ -190,6 +188,10 @@ public function list_names( $network = false ) { $names = array(); foreach ( $option_names as $option_name ) { + if ( ! is_string( $option_name ) ) { + continue; + } + $names[] = substr( $option_name, strlen( $prefix ) ); } @@ -204,7 +206,6 @@ public function list_names( $network = false ) { * * @param string $name The secret's namespaced name. * @param bool $network Whether this is a network-scope secret. - * * @return string */ private function option_name( $name, $network ) { diff --git a/src/wp-includes/secrets/interface-wp-secrets-keyring.php b/src/wp-includes/secrets/interface-wp-secrets-keyring.php index 3fe3c5b6a7e45..ba702b7922fc8 100644 --- a/src/wp-includes/secrets/interface-wp-secrets-keyring.php +++ b/src/wp-includes/secrets/interface-wp-secrets-keyring.php @@ -33,7 +33,6 @@ interface WP_Secrets_Keyring { * @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( $key_material ); @@ -44,7 +43,6 @@ public function wrap( $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( $wrapped ); diff --git a/src/wp-includes/secrets/interface-wp-secrets-provider.php b/src/wp-includes/secrets/interface-wp-secrets-provider.php index a5acafb94b343..89518ed74d377 100644 --- a/src/wp-includes/secrets/interface-wp-secrets-provider.php +++ b/src/wp-includes/secrets/interface-wp-secrets-provider.php @@ -90,7 +90,6 @@ interface WP_Secrets_Provider { * 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 */ public function get( $name, $version, $network = false ); @@ -124,7 +123,6 @@ public function get( $name, $version, $network = false ); * `wp_secret_changed`; null means the * provider decides between 'created' and * 'updated'. - * * @return true|WP_Error */ public function set( $name, $value, $network = false, $needs_rotation = false, $action = null ); @@ -138,7 +136,6 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ * * @param string $name The secret's name. * @param bool $network Whether this is a network-scope secret. - * * @return true|WP_Error */ public function delete( $name, $network = false ); @@ -153,7 +150,6 @@ public function delete( $name, $network = false ); * * @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( $name, $network = false ); @@ -166,9 +162,10 @@ public function retire_previous( $name, $network = false ); * @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|WP_Error */ public function list_secrets( $name_prefix = '', $network = false ); diff --git a/src/wp-includes/secrets/interface-wp-secrets-store.php b/src/wp-includes/secrets/interface-wp-secrets-store.php index 79d0ff24f211a..f2841479191a1 100644 --- a/src/wp-includes/secrets/interface-wp-secrets-store.php +++ b/src/wp-includes/secrets/interface-wp-secrets-store.php @@ -31,8 +31,7 @@ interface WP_Secrets_Store { * * @param string $name The secret's namespaced name. * @param bool $network Whether this is a network-scope secret. - * - * @return array|null|WP_Error The record array if it exists. Null if it does not. + * @return array|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( $name, $network = false ); @@ -42,10 +41,9 @@ public function get( $name, $network = false ); * * @since 7.2.0 * - * @param string $name The secret's namespaced name. - * @param array $record The record to store. - * @param bool $network Whether this is a network-scope secret. - * + * @param string $name The secret's namespaced name. + * @param array $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. */ @@ -58,7 +56,6 @@ public function set( $name, $record, $network = false ); * * @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. */ @@ -70,8 +67,7 @@ public function delete( $name, $network = false ); * @since 7.2.0 * * @param bool $network Whether to list network-scope secrets. - * - * @return array|WP_Error Array of secret names on success. WP_Error on failure. + * @return string[]|WP_Error Array of secret names on success. WP_Error on failure. */ public function list_names( $network = false ); } diff --git a/tests/phpunit/includes/secrets/class-mock-keyring.php b/tests/phpunit/includes/secrets/class-mock-keyring.php index a4925e226c005..999416082a24f 100644 --- a/tests/phpunit/includes/secrets/class-mock-keyring.php +++ b/tests/phpunit/includes/secrets/class-mock-keyring.php @@ -9,9 +9,32 @@ class Mock_Keyring implements WP_Secrets_Keyring { const MARKER = 'mock-wrapped:'; - private $fail_wrap = false; - private $fail_unwrap = false; - private $wrap_calls = 0; + /** + * 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 + */ + private $wrap_calls = 0; + + /** + * Number of times unwrap() has been called. + * + * @var int + */ private $unwrap_calls = 0; public function wrap( $key_material ) { diff --git a/tests/phpunit/includes/secrets/class-mock-store.php b/tests/phpunit/includes/secrets/class-mock-store.php index 0daaa193f7ffa..0167c2d671835 100644 --- a/tests/phpunit/includes/secrets/class-mock-store.php +++ b/tests/phpunit/includes/secrets/class-mock-store.php @@ -7,13 +7,30 @@ */ class Mock_Store implements WP_Secrets_Store { - private $records = array(); - private $fail = array( + /** + * Stored records, keyed by scope and name. + * + * @var array> + */ + private $records = array(); + + /** + * Which operations are configured to fail. + * + * @var array + */ + private $fail = array( 'get' => false, 'set' => false, 'delete' => false, 'list' => false, ); + + /** + * Every record ever passed to set(), in call order. + * + * @var array> + */ private $received_records = array(); public function get( $name, $network = false ) { @@ -82,12 +99,17 @@ public function configure_fail( $operation, $fail = true ) { * 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 + * @return array> */ public function get_received_records() { 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( $name, $network ) { return ( $network ? 'network:' : 'site:' ) . $name; } diff --git a/tests/phpunit/includes/secrets/class-wp-secrets-keyring-conformance.php b/tests/phpunit/includes/secrets/class-wp-secrets-keyring-conformance.php index 331911ee16472..15e3754eb9772 100644 --- a/tests/phpunit/includes/secrets/class-wp-secrets-keyring-conformance.php +++ b/tests/phpunit/includes/secrets/class-wp-secrets-keyring-conformance.php @@ -26,7 +26,7 @@ abstract protected function keyring(); * 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() { + public function test_wrap_returns_a_non_empty_string_that_unwraps_to_the_same_bytes(): void { $keyring = $this->keyring(); $key_material = random_bytes( 32 ); @@ -46,7 +46,7 @@ public function test_wrap_returns_a_non_empty_string_that_unwraps_to_the_same_by * 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() { + public function test_two_wraps_of_the_same_bytes_return_different_strings(): void { $keyring = $this->keyring(); $key_material = random_bytes( 32 ); @@ -62,7 +62,7 @@ public function test_two_wraps_of_the_same_bytes_return_different_strings() { * 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() { + public function test_unwrap_of_garbage_is_a_wp_error(): void { $keyring = $this->keyring(); $garbage = 'garbage-' . bin2hex( random_bytes( 16 ) ); @@ -74,10 +74,12 @@ public function test_unwrap_of_garbage_is_a_wp_error() { * 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() { + 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 ) ); @@ -88,10 +90,12 @@ public function test_unwrap_of_a_truncated_value_is_a_wp_error() { * 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() { + 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 ); @@ -103,7 +107,7 @@ public function test_unwrap_of_a_value_with_one_flipped_byte_is_a_wp_error() { * 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() { + public function test_get_key_source_returns_a_non_empty_string(): void { $source = $this->keyring()->get_key_source(); $this->assertIsString( $source ); diff --git a/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php b/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php index e26d13759b6f3..4df63af47ae16 100644 --- a/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php +++ b/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php @@ -49,8 +49,10 @@ protected function conformance_name() { * 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 ) { + 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.' ); } @@ -58,14 +60,14 @@ private function require_writable( WP_Secrets_Provider $provider ) { // -- declarations must be coherent ---------------------------------------- - public function test_reports_a_non_empty_label() { + 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() { + 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 ), @@ -73,7 +75,7 @@ public function test_reports_a_known_protection_boundary() { ); } - public function test_is_writable_returns_a_bool() { + public function test_is_writable_returns_a_bool(): void { $this->assertIsBool( $this->provider()->is_writable() ); } @@ -81,7 +83,7 @@ public function test_is_writable_returns_a_bool() { * 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() { + public function test_a_read_only_provider_actually_refuses_writes(): void { $provider = $this->provider(); if ( $provider->is_writable() ) { @@ -106,7 +108,7 @@ public function test_a_read_only_provider_actually_refuses_writes() { * 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() { + 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 ); @@ -117,7 +119,7 @@ public function test_a_name_that_was_never_set_is_null() { * 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() { + public function test_previous_on_a_provider_or_secret_without_history_is_null_not_error(): void { $provider = $this->provider(); $this->require_writable( $provider ); @@ -130,7 +132,7 @@ public function test_previous_on_a_provider_or_secret_without_history_is_null_no $this->assertNull( $previous ); } - public function test_deleting_something_absent_is_success() { + public function test_deleting_something_absent_is_success(): void { $provider = $this->provider(); $this->require_writable( $provider ); @@ -140,7 +142,7 @@ public function test_deleting_something_absent_is_success() { ); } - public function test_retiring_with_no_previous_version_is_success() { + public function test_retiring_with_no_previous_version_is_success(): void { $provider = $this->provider(); $this->require_writable( $provider ); @@ -152,7 +154,7 @@ public function test_retiring_with_no_previous_version_is_success() { // -- the round trip -------------------------------------------------------- - public function test_a_stored_value_comes_back_intact() { + public function test_a_stored_value_comes_back_intact(): void { $provider = $this->provider(); $this->require_writable( $provider ); @@ -171,22 +173,28 @@ public function test_a_stored_value_comes_back_intact() { * 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() { + 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 = $provider->get( $name, WP_Secret_Version::CURRENT )->fingerprint(); - $second = $provider->get( $name, WP_Secret_Version::CURRENT )->fingerprint(); + $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() { + public function test_a_deleted_secret_reads_as_absent(): void { $provider = $this->provider(); $this->require_writable( $provider ); @@ -199,7 +207,7 @@ public function test_a_deleted_secret_reads_as_absent() { // -- listing never leaks --------------------------------------------------- - public function test_listing_returns_an_array_or_an_error_and_never_a_value() { + public function test_listing_returns_an_array_or_an_error_and_never_a_value(): void { $provider = $this->provider(); $this->require_writable( $provider ); @@ -209,23 +217,28 @@ public function test_listing_returns_an_array_or_an_error_and_never_a_value() { $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', - wp_json_encode( $entries ), + $dump, 'list_secrets() must never expose a plaintext.' ); } - public function test_listing_includes_a_stored_secret() { + public function test_listing_includes_a_stored_secret(): void { $provider = $this->provider(); $this->require_writable( $provider ); $name = $this->conformance_name(); $provider->set( $name, 'value' ); - $names = wp_list_pluck( $provider->list_secrets(), 'name' ); + $entries = $provider->list_secrets(); - $this->assertContains( $name, $names ); + $this->assertNotWPError( $entries ); + $this->assertContains( $name, wp_list_pluck( $entries, 'name' ) ); } /** @@ -233,14 +246,18 @@ public function test_listing_includes_a_stored_secret() { * 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() { + 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' ); - $names = wp_list_pluck( $provider->list_secrets( 'conformance-a' ), 'name' ); + $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 ); diff --git a/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php b/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php index 8b9c180b3b6df..0f4ba638678c6 100644 --- a/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php +++ b/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php @@ -24,7 +24,7 @@ trait WP_Secrets_Assertions { * @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 = '' ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- PHPUnit assertion naming, like assertWPError(). + 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 ) ) { @@ -51,7 +51,7 @@ public function assertIsSecret( $maybe_secret, $expected = null, $message = '' ) * @param string $expected Expected plaintext. * @param bool $network Whether this is a network-scope secret. */ - public function assertRecordSlotDecryptsTo( $name, $slot, $expected, $network = false ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- PHPUnit assertion naming, like assertWPError(). + 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 ) ); @@ -68,7 +68,7 @@ public function assertRecordSlotDecryptsTo( $name, $slot, $expected, $network = * @param mixed $haystack Structure to search. * @param string $message Optional failure message. */ - public function assertNeverContainsPlaintext( $plaintext, $haystack, $message = '' ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- PHPUnit assertion naming, like assertWPError(). + 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. diff --git a/tests/phpunit/tests/secrets/brokenDropinFallbacks.php b/tests/phpunit/tests/secrets/brokenDropinFallbacks.php index 038c5b89e89ce..4836b59ef0fde 100644 --- a/tests/phpunit/tests/secrets/brokenDropinFallbacks.php +++ b/tests/phpunit/tests/secrets/brokenDropinFallbacks.php @@ -8,58 +8,58 @@ */ class Tests_Secrets_BrokenDropinFallbacks extends WP_UnitTestCase { - public function test_broken_store_implements_the_interface() { - $this->assertInstanceOf( WP_Secrets_Store::class, new WP_Secrets_Broken_Store() ); + 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() { + 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() { + 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() { + 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() { + 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() { - $this->assertInstanceOf( WP_Secrets_Keyring::class, new WP_Secrets_Broken_Keyring() ); + 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() { + 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() { + 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() { + 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() { + public function test_wp_using_secrets_dropin_is_false_by_default(): void { $this->assertFalse( wp_using_secrets_dropin() ); } @@ -67,7 +67,7 @@ public function test_wp_using_secrets_dropin_is_false_by_default() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_wp_using_secrets_dropin_reports_true_once_the_loader_flag_is_set() { + 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. diff --git a/tests/phpunit/tests/secrets/capabilities.php b/tests/phpunit/tests/secrets/capabilities.php index 5be5bb155c8a0..de6cab23ae869 100644 --- a/tests/phpunit/tests/secrets/capabilities.php +++ b/tests/phpunit/tests/secrets/capabilities.php @@ -26,27 +26,41 @@ public function tear_down() { parent::tear_down(); } - public function test_capability_constant_values() { - $this->assertSame( 'manage_secrets', WP_SECRETS_CAP_MANAGE ); - $this->assertSame( 'manage_network_secrets', WP_SECRETS_CAP_MANAGE_NETWORK ); + /** + * 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_a_fresh_install_grants_manage_secrets_to_administrator() { - $this->assertTrue( get_role( 'administrator' )->has_cap( WP_SECRETS_CAP_MANAGE ) ); + 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_no_other_role_is_granted_manage_secrets() { + 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( get_role( $role )->has_cap( WP_SECRETS_CAP_MANAGE ), $role ); + $this->assertFalse( $this->role( $role )->has_cap( WP_SECRETS_CAP_MANAGE ), $role ); } } - public function test_populate_roles_720_grants_manage_secrets_to_administrator() { - get_role( 'administrator' )->remove_cap( WP_SECRETS_CAP_MANAGE ); + 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( get_role( 'administrator' )->has_cap( WP_SECRETS_CAP_MANAGE ) ); + $this->assertTrue( $this->role( 'administrator' )->has_cap( WP_SECRETS_CAP_MANAGE ) ); } /** @@ -55,21 +69,26 @@ public function test_populate_roles_720_grants_manage_secrets_to_administrator() * @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 ) { + 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; - get_role( 'administrator' )->remove_cap( WP_SECRETS_CAP_MANAGE ); + $this->role( 'administrator' )->remove_cap( WP_SECRETS_CAP_MANAGE ); upgrade_720(); $wp_current_db_version = $saved; - $this->assertSame( $expect_granted, get_role( 'administrator' )->has_cap( WP_SECRETS_CAP_MANAGE ) ); + $this->assertSame( $expect_granted, $this->role( 'administrator' )->has_cap( WP_SECRETS_CAP_MANAGE ) ); } + /** + * Data provider. + * + * @return array + */ public function data_upgrade_720_gate() { return array( 'upgrading from 7.0' => array( 61644, true ), @@ -79,13 +98,13 @@ public function data_upgrade_720_gate() { ); } - public function test_administrator_user_has_manage_secrets() { + 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() { + 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 ) ); @@ -94,7 +113,7 @@ public function test_subscriber_does_not_have_manage_secrets() { /** * @group ms-excluded */ - public function test_single_site_administrator_does_not_have_manage_network_secrets() { + 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 ) ); @@ -103,7 +122,7 @@ public function test_single_site_administrator_does_not_have_manage_network_secr /** * @group ms-required */ - public function test_only_super_admins_have_manage_network_secrets() { + 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 ); diff --git a/tests/phpunit/tests/secrets/errorCodes.php b/tests/phpunit/tests/secrets/errorCodes.php index 75304d567203a..e529525478c29 100644 --- a/tests/phpunit/tests/secrets/errorCodes.php +++ b/tests/phpunit/tests/secrets/errorCodes.php @@ -7,17 +7,32 @@ */ class Tests_Secrets_ErrorCodes extends WP_UnitTestCase { - public function test_error_code_values_match_the_published_contract() { - $this->assertSame( 'secret_decryption_failed', WP_SECRETS_ERROR_DECRYPTION_FAILED ); - $this->assertSame( 'secret_key_unavailable', WP_SECRETS_ERROR_KEY_UNAVAILABLE ); - $this->assertSame( 'secret_store_unavailable', WP_SECRETS_ERROR_STORE_UNAVAILABLE ); - $this->assertSame( 'secret_invalid_name', WP_SECRETS_ERROR_INVALID_NAME ); - $this->assertSame( 'secret_invalid_value', WP_SECRETS_ERROR_INVALID_VALUE ); - $this->assertSame( 'secret_crypto_unavailable', WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE ); - $this->assertSame( 'secret_record_malformed', WP_SECRETS_ERROR_RECORD_MALFORMED ); + /** + * @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 ) ); } - public function test_all_error_codes_are_distinct() { + /** + * @return array + */ + 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, @@ -31,7 +46,7 @@ public function test_all_error_codes_are_distinct() { $this->assertSame( count( $codes ), count( array_unique( $codes ) ) ); } - public function test_max_name_length_matches_the_options_column_budget() { + 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 ); diff --git a/tests/phpunit/tests/secrets/extensionPoints.php b/tests/phpunit/tests/secrets/extensionPoints.php index c4011ab1ecf00..0173f164666c3 100644 --- a/tests/phpunit/tests/secrets/extensionPoints.php +++ b/tests/phpunit/tests/secrets/extensionPoints.php @@ -28,37 +28,49 @@ class Tests_Secrets_ExtensionPoints extends WP_UnitTestCase { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_mock_store_with_the_default_keyring_round_trips() { + 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' ) ); - $this->assertSame( 'value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + + $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() { + 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' ) ); - $this->assertSame( 'value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + + $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() { + 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' ) ); - $this->assertSame( 'value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + + $secret = wp_get_secret( 'myplugin/api-key' ); + + $this->assertInstanceOf( WP_Secret::class, $secret ); + $this->assertSame( 'value', $secret->reveal() ); } /** @@ -68,7 +80,7 @@ public function test_mock_store_and_mock_keyring_together_round_trip() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_read_succeeds_while_write_fails() { + 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 ) ) ); @@ -96,7 +108,7 @@ public function test_read_succeeds_while_write_fails() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_a_store_that_refuses_writes_still_serves_reads() { + 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 ) ) ); @@ -107,14 +119,18 @@ public function test_a_store_that_refuses_writes_still_serves_reads() { $store->configure_fail( 'set', true ); $this->assertWPError( wp_set_secret( 'myplugin/api-key', 'another' ) ); - $this->assertSame( 'value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + + $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() { + 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 ); @@ -133,7 +149,7 @@ public function test_unreachable_store_fails_closed_with_no_local_fallback() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_unreachable_keyring_fails_closed_with_no_local_fallback() { + 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' ); @@ -150,7 +166,7 @@ public function test_unreachable_keyring_fails_closed_with_no_local_fallback() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_the_store_is_never_handed_a_plaintext() { + public function test_the_store_is_never_handed_a_plaintext(): void { $store = new Mock_Store(); $GLOBALS['wp_secrets_store'] = $store; @@ -174,7 +190,7 @@ public function test_the_store_is_never_handed_a_plaintext() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_a_broken_dropin_state_fails_every_operation_closed() { + 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' ) ); diff --git a/tests/phpunit/tests/secrets/networkScope.php b/tests/phpunit/tests/secrets/networkScope.php index c2eba82107ad4..3d53b363c6162 100644 --- a/tests/phpunit/tests/secrets/networkScope.php +++ b/tests/phpunit/tests/secrets/networkScope.php @@ -8,7 +8,7 @@ class Tests_Secrets_NetworkScope extends WP_UnitTestCase { use WP_Secrets_Assertions; - public function test_set_then_get_round_trips() { + 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' ); @@ -17,35 +17,43 @@ public function test_set_then_get_round_trips() { $this->assertSame( 'value', $secret->reveal() ); } - public function test_get_returns_null_for_an_absent_secret() { + 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() { + 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' ); - $this->assertSame( 'site-value', wp_get_secret( 'myplugin/api-key' )->reveal() ); - $this->assertSame( 'network-value', wp_get_network_secret( 'myplugin/api-key' )->reveal() ); + $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() { + 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() { + 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' ); - $this->assertSame( 'site-value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + $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() { + 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' ); @@ -53,7 +61,7 @@ public function test_previous_version_and_demotion_work_the_same_as_site_scope() $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::CURRENT, 'second-value', true ); } - public function test_retire_clears_the_previous_slot() { + 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' ); @@ -61,16 +69,17 @@ public function test_retire_clears_the_previous_slot() { $this->assertNull( wp_get_network_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS ) ); } - public function test_list_returns_network_secrets_only() { + public function test_list_returns_network_secrets_only(): void { wp_set_secret( 'myplugin/site-only', 'value' ); wp_set_network_secret( 'myplugin/network-only', 'value' ); - $names = wp_list_pluck( wp_list_network_secrets(), 'name' ); + $entries = wp_list_network_secrets(); - $this->assertSame( array( 'myplugin/network-only' ), $names ); + $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() { + 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' ); @@ -79,7 +88,7 @@ public function test_an_invalid_version_is_a_wp_error_same_as_site_scope() { $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); } - public function test_set_rejects_an_invalid_name() { + public function test_set_rejects_an_invalid_name(): void { $result = wp_set_network_secret( 'Not A Valid Name', 'value' ); $this->assertWPError( $result ); @@ -90,7 +99,7 @@ public function test_set_rejects_an_invalid_name() { * 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() { + 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.' ); } @@ -110,7 +119,7 @@ public function test_network_secret_is_readable_from_any_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() { + 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.' ); } diff --git a/tests/phpunit/tests/secrets/provider.php b/tests/phpunit/tests/secrets/provider.php index bba82431f0a45..edb2fa22cc8e8 100644 --- a/tests/phpunit/tests/secrets/provider.php +++ b/tests/phpunit/tests/secrets/provider.php @@ -15,10 +15,24 @@ class Tests_Secrets_Provider extends WP_UnitTestCase { */ 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( $name, $version, $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( $name, $value, $network = false, $needs_rotation = false, $action = null ) { return new WP_Error( WP_SECRETS_ERROR_PROVIDER_READ_ONLY, @@ -26,26 +40,52 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ ); } + /** + * @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( $name, $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( $name, $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 + */ public function list_secrets( $name_prefix = '', $network = false ) { return array(); } + /** + * @return string + */ public function get_label() { return 'Example Platform (KMS)'; } + /** + * @return string + */ public function get_protection_boundary() { return self::BOUNDARY_PROVIDER; } + /** + * @return bool + */ public function is_writable() { return false; } @@ -54,18 +94,18 @@ public function is_writable() { // -- the shipped provider ------------------------------------------------- - public function test_the_default_provider_is_the_libsodium_one() { + 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() { + 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() { + public function test_the_default_provider_accepts_writes(): void { $this->assertTrue( wp_secrets_provider_is_writable() ); } @@ -74,7 +114,7 @@ public function test_the_default_provider_accepts_writes() { * 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() { + public function test_the_default_provider_label_names_the_key_source(): void { $label = wp_secrets_provider_label(); $this->assertStringContainsString( 'libsodium', $label ); @@ -90,7 +130,7 @@ public function test_the_default_provider_label_names_the_key_source() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_a_drop_in_provider_replaces_the_default_entirely() { + 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() ); @@ -108,7 +148,7 @@ public function test_a_drop_in_provider_replaces_the_default_entirely() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_a_read_only_provider_refuses_writes_and_declares_it() { + 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() ); @@ -126,7 +166,7 @@ public function test_a_read_only_provider_refuses_writes_and_declares_it() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_a_read_only_provider_still_serves_reads() { + 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' ) ); @@ -141,13 +181,13 @@ public function test_a_read_only_provider_still_serves_reads() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_a_broken_dropin_fails_closed_rather_than_reverting_to_the_default() { + 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->assertInstanceOf( 'WP_Secrets_Broken_Provider', $provider ); $this->assertNotInstanceOf( 'WP_Secrets_Libsodium_Provider', $provider ); + $this->assertInstanceOf( 'WP_Secrets_Broken_Provider', $provider ); $this->assertFalse( $provider->is_writable() ); } @@ -155,7 +195,7 @@ public function test_a_broken_dropin_fails_closed_rather_than_reverting_to_the_d * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_a_broken_dropin_makes_every_operation_a_wp_error() { + 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' ) ); @@ -172,7 +212,7 @@ public function test_a_broken_dropin_makes_every_operation_a_wp_error() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_a_broken_dropin_read_is_an_error_not_an_absence() { + 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' ) ); @@ -192,7 +232,7 @@ public function test_a_broken_dropin_read_is_an_error_not_an_absence() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_a_non_provider_global_fails_closed() { + 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() ); @@ -205,13 +245,13 @@ public function test_a_non_provider_global_fails_closed() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_a_non_provider_global_reads_as_an_error_not_an_absence() { + 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->assertWPError( $result ); $this->assertNotNull( $result ); + $this->assertWPError( $result ); } /** @@ -223,7 +263,7 @@ public function test_a_non_provider_global_reads_as_an_error_not_an_absence() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_a_keyring_only_dropin_still_gets_the_default_provider() { + 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() ); diff --git a/tests/phpunit/tests/secrets/readonlyProviderConformance.php b/tests/phpunit/tests/secrets/readonlyProviderConformance.php index 0b384b4fd9340..1aeb487473077 100644 --- a/tests/phpunit/tests/secrets/readonlyProviderConformance.php +++ b/tests/phpunit/tests/secrets/readonlyProviderConformance.php @@ -11,12 +11,31 @@ */ 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( $name, $version, $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( $name, $value, $network = false, $needs_rotation = false, $action = null ) { return new WP_Error( WP_SECRETS_ERROR_PROVIDER_READ_ONLY, @@ -24,26 +43,52 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ ); } + /** + * @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( $name, $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( $name, $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 + */ public function list_secrets( $name_prefix = '', $network = false ) { return array(); } + /** + * @return string + */ public function get_label() { return 'Example Platform (control panel)'; } + /** + * @return string + */ public function get_protection_boundary() { return self::BOUNDARY_PROVIDER; } + /** + * @return bool + */ public function is_writable() { return false; } diff --git a/tests/phpunit/tests/secrets/siteHealth.php b/tests/phpunit/tests/secrets/siteHealth.php index b138aa39bf267..de1dd9bc9656a 100644 --- a/tests/phpunit/tests/secrets/siteHealth.php +++ b/tests/phpunit/tests/secrets/siteHealth.php @@ -6,7 +6,31 @@ */ class Tests_Secrets_SiteHealth extends WP_UnitTestCase { - public function test_registers_three_tests_under_direct() { + /** + * 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> $info Debug information sections. + * @return array> 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 ); @@ -15,17 +39,18 @@ public function test_registers_three_tests_under_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() { + 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() { + 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.' ); } @@ -40,7 +65,7 @@ public function test_key_source_test_recommends_a_dedicated_key_by_default() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_key_source_test_recommends_regeneration_for_a_legacy_key() { + 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(); @@ -53,7 +78,7 @@ public function test_key_source_test_recommends_regeneration_for_a_legacy_key() * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_key_source_test_is_good_for_a_dedicated_base64_key() { + 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(); @@ -65,7 +90,7 @@ public function test_key_source_test_is_good_for_a_dedicated_base64_key() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_key_source_test_is_critical_when_the_keyring_is_broken() { + 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(); @@ -73,7 +98,7 @@ public function test_key_source_test_is_critical_when_the_keyring_is_broken() { $this->assertSame( 'critical', $result['status'] ); } - public function test_undecryptable_test_is_good_when_nothing_is_broken() { + 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(); @@ -81,17 +106,25 @@ public function test_undecryptable_test_is_good_when_nothing_is_broken() { $this->assertSame( 'good', $result['status'] ); } - public function test_undecryptable_test_is_good_with_no_secrets_at_all() { + 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() { + public function test_undecryptable_test_is_critical_and_lists_the_broken_secret(): void { wp_set_secret( 'myplugin/api-key', 'value' ); - $fingerprint = wp_get_secret( 'myplugin/api-key' )->fingerprint(); + $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 = get_option( '_wp_secret_myplugin/api-key' ); $record['current']['ct'] = base64_encode( 'not decryptable' ); update_option( '_wp_secret_myplugin/api-key', $record, false ); @@ -102,10 +135,15 @@ public function test_undecryptable_test_is_critical_and_lists_the_broken_secret( $this->assertStringContainsString( $fingerprint, $result['description'] ); } - public function test_undecryptable_test_never_contains_a_plaintext() { + 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' ); + $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 ); @@ -114,7 +152,7 @@ public function test_undecryptable_test_never_contains_a_plaintext() { $this->assertStringNotContainsString( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $result['description'] ); } - public function test_needs_rotation_test_is_good_by_default() { + 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(); @@ -122,7 +160,7 @@ public function test_needs_rotation_test_is_good_by_default() { $this->assertSame( 'good', $result['status'] ); } - public function test_needs_rotation_test_is_recommended_after_an_import() { + 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' ); @@ -132,13 +170,13 @@ public function test_needs_rotation_test_is_recommended_after_an_import() { $this->assertStringContainsString( '1', $result['description'] ); } - public function test_debug_info_reports_expected_fields() { + 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 = $info['secrets-api']['fields']; + $fields = $this->debug_fields( $info ); $this->assertArrayHasKey( 'dropin_active', $fields ); $this->assertArrayHasKey( 'provider_class', $fields ); @@ -153,33 +191,37 @@ public function test_debug_info_reports_expected_fields() { $this->assertSame( '1', $fields['site_secret_count']['value'] ); } - public function test_debug_info_reports_no_dropin_by_default() { + public function test_debug_info_reports_no_dropin_by_default(): void { $info = wp_secrets_site_health_debug_info( array() ); - $this->assertSame( 'No', $info['secrets-api']['fields']['dropin_active']['value'] ); + $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() { + 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() { + 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', $info['secrets-api']['fields'] ); + $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() { + public function test_debug_info_includes_network_fields_only_for_a_super_admin(): void { if ( ! is_multisite() ) { $this->markTestSkipped( 'Requires multisite.' ); } @@ -188,11 +230,11 @@ public function test_debug_info_includes_network_fields_only_for_a_super_admin() wp_set_current_user( $user_id ); $info = wp_secrets_site_health_debug_info( array() ); - $this->assertArrayNotHasKey( 'network_secret_count', $info['secrets-api']['fields'], 'A non-super-admin must not see network secret counts.' ); + $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', $info['secrets-api']['fields'] ); + $this->assertArrayHasKey( 'network_secret_count', $this->debug_fields( $info ) ); } } diff --git a/tests/phpunit/tests/secrets/threeStateContract.php b/tests/phpunit/tests/secrets/threeStateContract.php index 9037a8a1b3df7..74ce17eb31175 100644 --- a/tests/phpunit/tests/secrets/threeStateContract.php +++ b/tests/phpunit/tests/secrets/threeStateContract.php @@ -9,13 +9,13 @@ */ class Tests_Secrets_ThreeStateContract extends WP_UnitTestCase { - public function test_absent_is_null() { + 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() { + 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 ); @@ -23,7 +23,7 @@ public function test_previous_slot_absent_on_an_existing_secret_is_also_null() { $this->assertNull( $result ); } - public function test_malformed_record_is_wp_error_not_null() { + 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' ); @@ -33,7 +33,7 @@ public function test_malformed_record_is_wp_error_not_null() { $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() ); } - public function test_record_missing_the_current_slot_is_wp_error_not_null() { + 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' ); @@ -43,7 +43,7 @@ public function test_record_missing_the_current_slot_is_wp_error_not_null() { $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() ); } - public function test_unsupported_record_version_is_wp_error_not_null() { + public function test_unsupported_record_version_is_wp_error_not_null(): void { update_option( '_wp_secret_myplugin/api-key', array( @@ -72,7 +72,7 @@ public function test_unsupported_record_version_is_wp_error_not_null() { * 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() { + 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' ); @@ -99,7 +99,7 @@ public function test_aad_mismatch_from_a_copied_record_is_wp_error_not_null() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_key_unavailable_is_wp_error_not_null() { + 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.' ); } @@ -125,7 +125,7 @@ public function test_key_unavailable_is_wp_error_not_null() { * 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() { + 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' ); @@ -145,10 +145,15 @@ public function test_a_corrupted_wrapped_root_key_is_wp_error_not_null() { * 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() { + 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 ); @@ -164,18 +169,29 @@ public function test_record_missing_its_fingerprint_field_still_returns_a_secret * 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() { + public function test_fingerprint_is_recomputed_not_read_from_the_record(): void { wp_set_secret( 'myplugin/api-key', 'value' ); - $genuine = wp_get_secret( 'myplugin/api-key' )->fingerprint(); + $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 = get_option( '_wp_secret_myplugin/api-key' ); $record['current']['fingerprint'] = 'deadbeefdeadbeefdeadbeefdeadbeef'; update_option( '_wp_secret_myplugin/api-key', $record, false ); - $this->assertSame( $genuine, wp_get_secret( 'myplugin/api-key' )->fingerprint() ); + $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() { + 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' ); diff --git a/tests/phpunit/tests/secrets/versionSlots.php b/tests/phpunit/tests/secrets/versionSlots.php index 466ee831c9291..2714ceff6c775 100644 --- a/tests/phpunit/tests/secrets/versionSlots.php +++ b/tests/phpunit/tests/secrets/versionSlots.php @@ -11,7 +11,24 @@ class Tests_Secrets_VersionSlots extends WP_UnitTestCase { use WP_Secrets_Assertions; - public function test_first_write_leaves_no_previous_slot() { + /** + * 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 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 ) ); @@ -20,7 +37,7 @@ public function test_first_write_leaves_no_previous_slot() { /** * The headline case: PREVIOUS must actually decrypt back to the prior value. */ - public function test_previous_returns_the_actual_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' ); @@ -28,7 +45,7 @@ public function test_previous_returns_the_actual_prior_value() { $this->assertRecordSlotDecryptsTo( 'myplugin/api-key', WP_Secret_Version::CURRENT, 'second-value' ); } - public function test_third_write_discards_the_oldest_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' ); @@ -40,38 +57,45 @@ public function test_third_write_discards_the_oldest_value() { $this->assertNeverContainsPlaintext( 'value-a', get_option( '_wp_secret_myplugin/api-key' ) ); } - public function test_previous_preserves_its_original_fingerprint() { + public function test_previous_preserves_its_original_fingerprint(): void { wp_set_secret( 'myplugin/api-key', 'first-value' ); - $original_fingerprint = wp_get_secret( 'myplugin/api-key' )->fingerprint(); + $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' ); - $this->assertSame( $original_fingerprint, wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS )->fingerprint() ); + $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() { + public function test_previous_preserves_its_original_created_timestamp(): void { wp_set_secret( 'myplugin/api-key', 'first-value' ); - $original_created = get_option( '_wp_secret_myplugin/api-key' )['current']['created']; + $original_created = $this->stored_slot( WP_Secret_Version::CURRENT )['created']; wp_set_secret( 'myplugin/api-key', 'second-value' ); - $demoted_created = get_option( '_wp_secret_myplugin/api-key' )['previous']['created']; + $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() { + 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' ); + $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( get_option( '_wp_secret_myplugin/api-key' )['previous']['needs_rotation'] ); + $this->assertTrue( $this->stored_slot( WP_Secret_Version::PREVIOUS )['needs_rotation'] ); } /** @@ -81,20 +105,22 @@ public function test_previous_preserves_its_needs_rotation_flag() { * 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() { + public function test_demotion_actually_reencrypts_rather_than_copying_ciphertext(): void { wp_set_secret( 'myplugin/api-key', 'first-value' ); - $original_ciphertext = get_option( '_wp_secret_myplugin/api-key' )['current']['ct']; + $original_ciphertext = $this->stored_slot( WP_Secret_Version::CURRENT )['ct']; wp_set_secret( 'myplugin/api-key', 'second-value' ); - $demoted_ciphertext = get_option( '_wp_secret_myplugin/api-key' )['previous']['ct']; + $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. - $this->assertSame( 'first-value', wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS )->reveal() ); + $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() { + 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 ) ); @@ -106,49 +132,58 @@ public function test_previous_on_a_secret_with_no_previous_slot_is_null() { * 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() { + 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' ); + $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' ) ); - $this->assertSame( 'new-value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + $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() { + 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 ) ); - $this->assertSame( 'second-value', wp_get_secret( 'myplugin/api-key' )->reveal() ); + $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() { + 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() { + 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() { + 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() { + public function test_retire_fires_the_change_hook_with_the_retired_action(): void { wp_set_secret( 'myplugin/api-key', 'first-value' ); - $fingerprint_being_retired = wp_get_secret( 'myplugin/api-key' )->fingerprint(); + $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; @@ -163,6 +198,9 @@ function ( ...$args ) use ( &$captured ) { wp_retire_secret_version( 'myplugin/api-key' ); + $this->assertIsArray( $captured ); + $this->assertCount( 6, $captured ); + list( $name, $action, , , $old_fingerprint, $new_fingerprint ) = $captured; $this->assertSame( 'myplugin/api-key', $name ); @@ -171,7 +209,7 @@ function ( ...$args ) use ( &$captured ) { $this->assertSame( '', $new_fingerprint ); } - public function test_retire_does_not_fire_the_change_hook_on_a_noop() { + public function test_retire_does_not_fire_the_change_hook_on_a_noop(): void { wp_set_secret( 'myplugin/api-key', 'value' ); $fired = false; diff --git a/tests/phpunit/tests/secrets/wpDeleteSecret.php b/tests/phpunit/tests/secrets/wpDeleteSecret.php index 5d2dd73f09660..255ea281cbf07 100644 --- a/tests/phpunit/tests/secrets/wpDeleteSecret.php +++ b/tests/phpunit/tests/secrets/wpDeleteSecret.php @@ -6,27 +6,31 @@ */ class Tests_Secrets_WpDeleteSecret extends WP_UnitTestCase { - public function test_deletes_an_existing_secret() { + 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() { + 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() { + 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() { + public function test_change_hook_fires_with_deleted_action(): void { wp_set_secret( 'myplugin/api-key', 'value' ); - $fingerprint = wp_get_secret( 'myplugin/api-key' )->fingerprint(); + $secret = wp_get_secret( 'myplugin/api-key' ); + + $this->assertInstanceOf( WP_Secret::class, $secret ); + + $fingerprint = $secret->fingerprint(); $captured = null; add_action( @@ -40,6 +44,8 @@ function ( ...$args ) use ( &$captured ) { wp_delete_secret( 'myplugin/api-key' ); + $this->assertIsArray( $captured ); + list( $name, $action, , , $old_fingerprint, $new_fingerprint ) = $captured; $this->assertSame( 'myplugin/api-key', $name ); @@ -48,7 +54,7 @@ function ( ...$args ) use ( &$captured ) { $this->assertSame( '', $new_fingerprint ); } - public function test_change_hook_does_not_fire_when_nothing_was_deleted() { + public function test_change_hook_does_not_fire_when_nothing_was_deleted(): void { $fired = false; add_action( 'wp_secret_changed', @@ -67,14 +73,14 @@ function () use ( &$fired ) { * permanently stuck -- unreadable, unrepairable, and undeletable without editing * the database by hand. */ - public function test_a_corrupt_record_is_still_deletable() { + 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() { + public function test_no_capability_check_is_applied(): void { wp_set_secret( 'myplugin/api-key', 'value' ); wp_set_current_user( 0 ); diff --git a/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php b/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php index bd43deafea7f0..5dba3817a6d9b 100644 --- a/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php +++ b/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php @@ -6,14 +6,18 @@ */ class Tests_Secrets_WpImportOptionAsSecret extends WP_UnitTestCase { - public function test_imports_an_existing_option_value() { + 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' ) ); - $this->assertSame( 'sk_live_from_an_option', wp_get_secret( 'myplugin/api-key' )->reveal() ); + + $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() { + 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' ); @@ -21,31 +25,37 @@ public function test_leaves_the_source_option_untouched() { $this->assertSame( 'sk_live_from_an_option', get_option( 'my_plugin_api_key' ) ); } - public function test_flags_the_imported_secret_for_rotation() { + 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() { + 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() { + 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() { + 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' ); @@ -54,7 +64,7 @@ public function test_rejects_a_non_string_option_value() { $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() ); } - public function test_rejects_an_invalid_destination_name() { + 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' ); @@ -63,7 +73,7 @@ public function test_rejects_an_invalid_destination_name() { $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); } - public function test_fires_the_change_hook_with_the_imported_action() { + public function test_fires_the_change_hook_with_the_imported_action(): void { update_option( 'my_plugin_api_key', 'value' ); $captured = null; @@ -78,6 +88,7 @@ function ( ...$args ) use ( &$captured ) { 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 ); @@ -89,13 +100,18 @@ function ( ...$args ) use ( &$captured ) { * 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() { + 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' ); - $this->assertSame( 'imported-value', wp_get_secret( 'myplugin/api-key' )->reveal() ); - $this->assertSame( 'original-value', wp_get_secret( 'myplugin/api-key', WP_Secret_Version::PREVIOUS )->reveal() ); + $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() ); } } diff --git a/tests/phpunit/tests/secrets/wpListSecrets.php b/tests/phpunit/tests/secrets/wpListSecrets.php index 720c406c7cbe7..01e6878d96916 100644 --- a/tests/phpunit/tests/secrets/wpListSecrets.php +++ b/tests/phpunit/tests/secrets/wpListSecrets.php @@ -6,15 +6,16 @@ */ class Tests_Secrets_WpListSecrets extends WP_UnitTestCase { - public function test_returns_an_empty_array_when_nothing_is_set() { + 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() { + 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' ), @@ -27,70 +28,91 @@ public function test_lists_every_secret_with_the_expected_keys() { * Never a value, under any circumstance -- the entire justification for this * function existing depends on it. */ - public function test_never_returns_a_value() { + 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() { + public function test_fingerprint_matches_the_secrets_own_fingerprint(): void { wp_set_secret( 'myplugin/api-key', 'value' ); - $fingerprint = wp_get_secret( 'myplugin/api-key' )->fingerprint(); - $entries = wp_list_secrets(); + $secret = wp_get_secret( 'myplugin/api-key' ); + $entries = wp_list_secrets(); - $this->assertSame( $fingerprint, $entries[0]['fingerprint'] ); + $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() { + public function test_has_previous_is_false_before_a_rotation_and_true_after(): void { wp_set_secret( 'myplugin/api-key', 'first-value' ); - $this->assertFalse( wp_list_secrets()[0]['has_previous'] ); + $before = wp_list_secrets(); + + $this->assertIsArray( $before ); + $this->assertFalse( $before[0]['has_previous'] ); wp_set_secret( 'myplugin/api-key', 'second-value' ); - $this->assertTrue( wp_list_secrets()[0]['has_previous'] ); + $after = wp_list_secrets(); + + $this->assertIsArray( $after ); + $this->assertTrue( $after[0]['has_previous'] ); } - public function test_needs_rotation_reflects_the_current_slots_flag() { + 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' ); + $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 ); - $this->assertTrue( wp_list_secrets()[0]['needs_rotation'] ); + $entries = wp_list_secrets(); + + $this->assertIsArray( $entries ); + $this->assertTrue( $entries[0]['needs_rotation'] ); } - public function test_filters_by_namespace() { + public function test_filters_by_namespace(): void { wp_set_secret( 'pluginone/key', 'value' ); wp_set_secret( 'plugintwo/key', 'value' ); - $names = wp_list_pluck( wp_list_secrets( 'pluginone' ), 'name' ); + $entries = wp_list_secrets( 'pluginone' ); - $this->assertSame( array( 'pluginone/key' ), $names ); + $this->assertIsArray( $entries ); + $this->assertSame( array( 'pluginone/key' ), wp_list_pluck( $entries, 'name' ) ); } - public function test_empty_namespace_returns_everything() { + public function test_empty_namespace_returns_everything(): void { wp_set_secret( 'pluginone/key', 'value' ); wp_set_secret( 'plugintwo/key', 'value' ); - $this->assertCount( 2, wp_list_secrets( '' ) ); + $entries = wp_list_secrets( '' ); + + $this->assertIsArray( $entries ); + $this->assertCount( 2, $entries ); } - public function test_namespace_does_not_match_a_prefix_of_a_different_namespace() { + 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' ) ); } - public function test_a_non_string_namespace_is_a_wp_error_not_an_exception() { + public function test_a_non_string_namespace_is_a_wp_error_not_an_exception(): void { $this->setExpectedIncorrectUsage( '_wp_secrets_list' ); - $result = wp_list_secrets( array( 'not', 'a', 'string' ) ); + $result = wp_list_secrets( array( 'not', 'a', 'string' ) ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.) $this->assertWPError( $result ); $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); @@ -100,23 +122,25 @@ public function test_a_non_string_namespace_is_a_wp_error_not_an_exception() { * 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() { + 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() { + 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 ); - $names = wp_list_pluck( wp_list_secrets(), 'name' ); + $entries = wp_list_secrets(); - $this->assertSame( array( 'myplugin/site-only' ), $names ); + $this->assertIsArray( $entries ); + $this->assertSame( array( 'myplugin/site-only' ), wp_list_pluck( $entries, 'name' ) ); } } diff --git a/tests/phpunit/tests/secrets/wpSecret.php b/tests/phpunit/tests/secrets/wpSecret.php index 963a7cd5c7502..5a1b2dcab8f4c 100644 --- a/tests/phpunit/tests/secrets/wpSecret.php +++ b/tests/phpunit/tests/secrets/wpSecret.php @@ -18,51 +18,54 @@ class Tests_Secrets_WPSecret extends WP_UnitTestCase { 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() { + public function test_class_is_final(): void { $reflection = new ReflectionClass( WP_Secret::class ); $this->assertTrue( $reflection->isFinal() ); } - public function test_implements_json_serializable() { + public function test_implements_json_serializable(): void { $this->assertInstanceOf( JsonSerializable::class, $this->make_secret() ); } - public function test_reveal_returns_the_exact_plaintext() { + 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() { + 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() { + 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() { + public function test_constructor_rejects_a_non_string_value(): void { $this->expectException( InvalidArgumentException::class ); - new WP_Secret( self::NAME, 12345, self::FINGERPRINT ); + 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() { + 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() { + public function test_constructor_rejects_an_empty_fingerprint(): void { $this->expectException( InvalidArgumentException::class ); new WP_Secret( self::NAME, self::PLAINTEXT, '' ); @@ -72,34 +75,40 @@ public function test_constructor_rejects_an_empty_fingerprint() { * 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 ) { + 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 + */ public function data_masking_surfaces() { return array( '(string) cast' => array( - function ( $secret ) { + function ( WP_Secret $secret ) { return (string) $secret; }, ), 'string interpolation' => array( - function ( $secret ) { + function ( WP_Secret $secret ) { return "{$secret}"; }, ), 'json_encode' => array( - function ( $secret ) { + function ( WP_Secret $secret ) { return json_encode( $secret ); }, ), 'var_dump' => array( - function ( $secret ) { + function ( WP_Secret $secret ) { ob_start(); var_dump( $secret ); @@ -107,17 +116,17 @@ function ( $secret ) { }, ), 'print_r' => array( - function ( $secret ) { + function ( WP_Secret $secret ) { return print_r( $secret, true ); }, ), 'var_export' => array( - function ( $secret ) { + function ( WP_Secret $secret ) { return var_export( $secret, true ); }, ), 'error_log' => array( - function ( $secret ) { + function ( WP_Secret $secret ) { $file = tempnam( sys_get_temp_dir(), 'wp-secret-test-' ); error_log( $secret, 3, $file ); $contents = file_get_contents( $file ); @@ -129,20 +138,23 @@ function ( $secret ) { ); } - public function test_to_string_yields_the_masked_placeholder() { + 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() { + 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. - $this->assertSame( '[secret:' . self::NAME . ']', json_decode( json_encode( $secret ) ) ); + $json = json_encode( $secret ); + + $this->assertIsString( $json ); + $this->assertSame( '[secret:' . self::NAME . ']', json_decode( $json ) ); } - public function test_var_dump_yields_the_masked_placeholder() { + public function test_var_dump_yields_the_masked_placeholder(): void { $secret = $this->make_secret(); ob_start(); @@ -152,7 +164,7 @@ public function test_var_dump_yields_the_masked_placeholder() { $this->assertStringContainsString( '[secret:' . self::NAME . ']', $output ); } - public function test_serialize_throws() { + public function test_serialize_throws(): void { $secret = $this->make_secret(); $this->expectException( LogicException::class ); @@ -160,12 +172,12 @@ public function test_serialize_throws() { serialize( $secret ); } - public function test_clone_throws() { + public function test_clone_throws(): void { $secret = $this->make_secret(); $this->expectException( LogicException::class ); - clone $secret; + $clone = clone $secret; } /** @@ -174,8 +186,11 @@ public function test_clone_throws() { * 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 $args Arguments to invoke it with. */ - public function test_serialization_magic_methods_throw_directly( $method, $args ) { + 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 ) { @@ -187,6 +202,9 @@ public function test_serialization_magic_methods_throw_directly( $method, $args $reflection->invokeArgs( $secret, $args ); } + /** + * @return array}> + */ public function data_refusing_magic_methods() { return array( '__sleep' => array( '__sleep', array() ), @@ -196,7 +214,7 @@ public function data_refusing_magic_methods() { ); } - public function test_destructing_the_last_reference_removes_it_from_the_vault() { + public function test_destructing_the_last_reference_removes_it_from_the_vault(): void { $secret = $this->make_secret(); $id = spl_object_id( $secret ); @@ -205,11 +223,17 @@ public function test_destructing_the_last_reference_removes_it_from_the_vault() $vault_property->setAccessible( true ); } - $this->assertArrayHasKey( $id, $vault_property->getValue() ); + $vault = $vault_property->getValue(); + + $this->assertIsArray( $vault ); + $this->assertArrayHasKey( $id, $vault ); unset( $secret ); - $this->assertArrayNotHasKey( $id, $vault_property->getValue() ); + $vault = $vault_property->getValue(); + + $this->assertIsArray( $vault ); + $this->assertArrayNotHasKey( $id, $vault ); } /** @@ -221,7 +245,7 @@ public function test_destructing_the_last_reference_removes_it_from_the_vault() * __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() { + public function test_wp_cache_set_of_a_secret_is_refused(): void { $secret = $this->make_secret(); $this->expectException( LogicException::class ); @@ -240,7 +264,7 @@ public function test_wp_cache_set_of_a_secret_is_refused() { * 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() { + public function test_nothing_plaintext_survives_a_refused_cache_write(): void { $secret = $this->make_secret(); try { @@ -254,7 +278,7 @@ public function test_nothing_plaintext_survives_a_refused_cache_write() { $this->assertNeverContainsPlaintext( self::PLAINTEXT, $cached ); } - public function test_two_instances_do_not_share_a_vault_slot() { + 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' ); @@ -274,7 +298,7 @@ public function test_two_instances_do_not_share_a_vault_slot() { * normally, which is the point -- it still lists, still fingerprints, and * still masks itself everywhere. */ - public function test_withheld_reveal_returns_the_providers_reason() { + 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 ); @@ -284,7 +308,7 @@ public function test_withheld_reveal_returns_the_providers_reason() { $this->assertSame( 'provider_withholds_value', $revealed->get_error_code() ); } - public function test_withheld_still_reports_name_and_fingerprint() { + public function test_withheld_still_reports_name_and_fingerprint(): void { $secret = WP_Secret::withheld( 'myplugin/signing-key', 'abc123', @@ -299,7 +323,7 @@ public function test_withheld_still_reports_name_and_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() { + public function test_withheld_masks_like_any_other_secret(): void { $secret = WP_Secret::withheld( 'myplugin/signing-key', 'abc123', @@ -312,17 +336,17 @@ public function test_withheld_masks_like_any_other_secret() { $this->assertStringContainsString( '[secret:myplugin/signing-key]', (string) $secret ); } - public function test_withheld_requires_a_wp_error_reason() { + public function test_withheld_requires_a_wp_error_reason(): void { $this->expectException( InvalidArgumentException::class ); - WP_Secret::withheld( 'myplugin/signing-key', 'abc123', 'not an error' ); + WP_Secret::withheld( 'myplugin/signing-key', 'abc123', 'not an error' ); // @phpstan-ignore argument.type (Intentionally passing a reason that is not a WP_Error.) } /** * 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() { + 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() ); diff --git a/tests/phpunit/tests/secrets/wpSecretVersion.php b/tests/phpunit/tests/secrets/wpSecretVersion.php index 620c1de88042c..c972472bb4c91 100644 --- a/tests/phpunit/tests/secrets/wpSecretVersion.php +++ b/tests/phpunit/tests/secrets/wpSecretVersion.php @@ -4,25 +4,25 @@ */ class Tests_Secrets_WPSecretVersion extends WP_UnitTestCase { - public function test_current_and_previous_are_distinct_strings() { + 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() { + 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() { + 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() { + public function test_class_declares_exactly_two_constants(): void { $reflection = new ReflectionClass( WP_Secret_Version::class ); $this->assertCount( 2, $reflection->getConstants() ); diff --git a/tests/phpunit/tests/secrets/wpSecretsCipher.php b/tests/phpunit/tests/secrets/wpSecretsCipher.php index d5848bc36b02d..4977682e0e9ef 100644 --- a/tests/phpunit/tests/secrets/wpSecretsCipher.php +++ b/tests/phpunit/tests/secrets/wpSecretsCipher.php @@ -8,6 +8,14 @@ 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 $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 ) { @@ -17,11 +25,17 @@ private function invoke_private( $instance, $method, array $args = array() ) { 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() { + 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' ); @@ -32,7 +46,7 @@ public function test_round_trips_a_value() { $this->assertSame( 'sk_live_secret', $plaintext ); } - public function test_round_trips_an_empty_string_value() { + 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, '' ); @@ -41,7 +55,7 @@ public function test_round_trips_an_empty_string_value() { $this->assertSame( '', $plaintext ); } - public function test_round_trips_under_network_scope() { + 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' ); @@ -50,10 +64,10 @@ public function test_round_trips_under_network_scope() { $this->assertSame( 'network-secret', $plaintext ); } - public function test_encrypt_rejects_a_non_string_value() { + 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() ); + $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() ); @@ -61,16 +75,21 @@ public function test_encrypt_rejects_a_non_string_value() { /** * @dataProvider data_bad_master_keys + * + * @param mixed $bad_key Master key the cipher must refuse. */ - public function test_encrypt_rejects_a_bad_master_key( $bad_key ) { + 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' ); + $result = $cipher->encrypt_value( $bad_key, 'site', 1, self::NAME, WP_Secret_Version::CURRENT, 'value' ); // @phpstan-ignore argument.type (Intentionally passing an invalid master key, including a non-string.) $this->assertWPError( $result ); $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); } + /** + * @return array + */ public function data_bad_master_keys() { return array( 'too short' => array( str_repeat( 'a', 31 ) ), @@ -80,7 +99,7 @@ public function data_bad_master_keys() { ); } - public function test_encrypt_propagates_an_invalid_name() { + 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' ); @@ -89,7 +108,7 @@ public function test_encrypt_propagates_an_invalid_name() { $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); } - public function test_encrypt_reports_an_invalid_scope_as_a_wp_error() { + 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()' ); @@ -100,7 +119,7 @@ public function test_encrypt_reports_an_invalid_scope_as_a_wp_error() { $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); } - public function test_encrypt_reports_a_negative_site_id_as_a_wp_error() { + 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()' ); @@ -111,7 +130,7 @@ public function test_encrypt_reports_a_negative_site_id_as_a_wp_error() { $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); } - public function test_encrypt_reports_an_invalid_slot_as_a_wp_error() { + 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()' ); @@ -127,8 +146,13 @@ public function test_encrypt_reports_an_invalid_slot_as_a_wp_error() { * 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. */ - public function test_decrypt_fails_when_context_does_not_match( $scope, $site_id, $name, $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' ); @@ -138,6 +162,9 @@ public function test_decrypt_fails_when_context_does_not_match( $scope, $site_id $this->assertSame( WP_SECRETS_ERROR_DECRYPTION_FAILED, $result->get_error_code() ); } + /** + * @return array + */ public function data_mismatched_context() { return array( 'different scope' => array( 'network', 1, self::NAME, WP_Secret_Version::CURRENT ), @@ -147,7 +174,7 @@ public function data_mismatched_context() { ); } - public function test_decrypt_fails_under_a_different_master_key() { + 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' ); @@ -159,12 +186,20 @@ public function test_decrypt_fails_under_a_different_master_key() { /** * @dataProvider data_tamperable_fields + * + * @param string $field Record field to corrupt. */ - public function test_decrypt_fails_when_a_field_is_tampered( $field ) { + 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' ); - $raw = base64_decode( $record[ $field ], true ); + $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 ); @@ -174,6 +209,9 @@ public function test_decrypt_fails_when_a_field_is_tampered( $field ) { $this->assertWPError( $result ); } + /** + * @return array + */ public function data_tamperable_fields() { return array( 'ciphertext' => array( 'ct' ), @@ -185,8 +223,10 @@ public function data_tamperable_fields() { /** * @dataProvider data_malformed_records + * + * @param mixed $record Stored record the cipher must refuse. */ - public function test_decrypt_rejects_a_malformed_record( $record ) { + 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 ); @@ -195,6 +235,9 @@ public function test_decrypt_rejects_a_malformed_record( $record ) { $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() ); } + /** + * @return array + */ public function data_malformed_records() { return array( 'not an array' => array( 'just a string' ), @@ -224,22 +267,28 @@ public function data_malformed_records() { ); } - public function test_data_key_independence() { + 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() { + 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' ); + $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']; } @@ -247,7 +296,7 @@ public function test_nonce_uniqueness_across_many_writes() { $this->assertSame( count( $nonces ), count( array_unique( $nonces ) ) ); } - public function test_fingerprint_is_stable_for_the_same_inputs() { + public function test_fingerprint_is_stable_for_the_same_inputs(): void { $cipher = new WP_Secrets_Cipher(); $this->assertSame( @@ -256,7 +305,7 @@ public function test_fingerprint_is_stable_for_the_same_inputs() { ); } - public function test_fingerprint_differs_across_master_keys() { + public function test_fingerprint_differs_across_master_keys(): void { $cipher = new WP_Secrets_Cipher(); $this->assertNotSame( @@ -265,7 +314,7 @@ public function test_fingerprint_differs_across_master_keys() { ); } - public function test_fingerprint_differs_across_plaintexts() { + public function test_fingerprint_differs_across_plaintexts(): void { $cipher = new WP_Secrets_Cipher(); $this->assertNotSame( @@ -274,7 +323,7 @@ public function test_fingerprint_differs_across_plaintexts() { ); } - public function test_fingerprint_rejects_a_bad_master_key() { + public function test_fingerprint_rejects_a_bad_master_key(): void { $cipher = new WP_Secrets_Cipher(); $result = $cipher->fingerprint( 'too-short', 'value' ); @@ -283,10 +332,10 @@ public function test_fingerprint_rejects_a_bad_master_key() { $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); } - public function test_fingerprint_rejects_a_non_string_value() { + public function test_fingerprint_rejects_a_non_string_value(): void { $cipher = new WP_Secrets_Cipher(); - $result = $cipher->fingerprint( $this->master_key(), array() ); + $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() ); @@ -299,7 +348,7 @@ public function test_fingerprint_rejects_a_non_string_value() { * 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() { + 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' ); @@ -312,7 +361,7 @@ public function test_fingerprint_known_answer_vector() { * 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() { + public function test_aad_format_known_answer(): void { $cipher = new WP_Secrets_Cipher(); $aad = $this->invoke_private( @@ -333,7 +382,7 @@ public function test_aad_format_known_answer() { * 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() { + 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'; diff --git a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php index 6fc137edb149a..6d0dcfcf8ac58 100644 --- a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php +++ b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php @@ -13,7 +13,15 @@ */ class Tests_Secrets_WPSecretsConfigKeyProvider extends WP_UnitTestCase { - private function invoke_private( $instance, $method, array $args = array() ) { + /** + * Calls a private method on an instance. + * + * @param object $instance Object to call the method on. + * @param string $method Method name. + * @param array $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 ); @@ -22,11 +30,11 @@ private function invoke_private( $instance, $method, array $args = array() ) { return $reflection->invokeArgs( $instance, $args ); } - public function test_implements_the_keyring_interface() { - $this->assertInstanceOf( WP_Secrets_Keyring::class, new WP_Secrets_Config_Key_Provider() ); + 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() { + public function test_wrap_then_unwrap_round_trips(): void { $provider = new WP_Secrets_Config_Key_Provider(); $material = random_bytes( 32 ); @@ -37,16 +45,16 @@ public function test_wrap_then_unwrap_round_trips() { $this->assertSame( $material, $unwrapped ); } - public function test_wrap_rejects_a_non_string() { + public function test_wrap_rejects_a_non_string(): void { $provider = new WP_Secrets_Config_Key_Provider(); - $result = $provider->wrap( 12345 ); + $result = $provider->wrap( 12345 ); // @phpstan-ignore argument.type (Intentionally passing a non-string to exercise the runtime guard.) $this->assertWPError( $result ); $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() ); } - public function test_wrap_rejects_an_empty_string() { + public function test_wrap_rejects_an_empty_string(): void { $provider = new WP_Secrets_Config_Key_Provider(); $result = $provider->wrap( '' ); @@ -55,23 +63,23 @@ public function test_wrap_rejects_an_empty_string() { $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() ); } - public function test_two_wraps_of_the_same_material_produce_different_output() { + 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_a_non_string() { + public function test_unwrap_rejects_a_non_string(): void { $provider = new WP_Secrets_Config_Key_Provider(); - $result = $provider->unwrap( array() ); + $result = $provider->unwrap( array() ); // @phpstan-ignore argument.type (Intentionally passing a non-string to exercise the runtime guard.) $this->assertWPError( $result ); $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); } - public function test_unwrap_rejects_garbage_base64() { + public function test_unwrap_rejects_garbage_base64(): void { $provider = new WP_Secrets_Config_Key_Provider(); $result = $provider->unwrap( 'not valid base64 at all!!!' ); @@ -80,7 +88,7 @@ public function test_unwrap_rejects_garbage_base64() { $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); } - public function test_unwrap_rejects_data_too_short_to_contain_a_nonce() { + 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' ) ); @@ -89,11 +97,14 @@ public function test_unwrap_rejects_data_too_short_to_contain_a_nonce() { $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); } - public function test_unwrap_rejects_a_tampered_ciphertext() { + 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 ); - $raw = base64_decode( $wrapped, true ); $tampered_raw = $raw; $last_byte = strlen( $tampered_raw ) - 1; $tampered_raw[ $last_byte ] = chr( ( ord( $tampered_raw[ $last_byte ] ) + 1 ) % 256 ); @@ -104,7 +115,7 @@ public function test_unwrap_rejects_a_tampered_ciphertext() { $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() { + 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.' ); } @@ -116,14 +127,20 @@ public function test_get_key_source_reports_the_salt_fallback_when_wp_secrets_ke /** * @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 ) { + 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 ) ) ); } - public function data_canonical_base64_32() { + /** + * @return array + */ + 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 ), @@ -137,14 +154,21 @@ public function data_canonical_base64_32() { /** * @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 ) { + 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 ) ) ); } - public function data_usable_salt_values() { + /** + * @return array + */ + 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 ), @@ -159,7 +183,7 @@ public function data_usable_salt_values() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_derives_raw_bytes_when_wp_secrets_key_is_canonical_base64_32() { + 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 ) ); @@ -177,12 +201,13 @@ public function test_derives_raw_bytes_when_wp_secrets_key_is_canonical_base64_3 * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_derives_a_known_answer_via_the_legacy_interpretation() { + 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 ) @@ -197,7 +222,7 @@ public function test_derives_a_known_answer_via_the_legacy_interpretation() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_wrong_length_base64_falls_through_to_legacy_interpretation() { + 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 ); @@ -214,7 +239,7 @@ public function test_wrong_length_base64_falls_through_to_legacy_interpretation( * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_non_string_wp_secrets_key_is_unavailable() { + public function test_non_string_wp_secrets_key_is_unavailable(): void { define( 'WP_SECRETS_KEY', 424242 ); $provider = new WP_Secrets_Config_Key_Provider(); @@ -228,7 +253,7 @@ public function test_non_string_wp_secrets_key_is_unavailable() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_get_key_source_reports_base64_form() { + 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(); @@ -240,7 +265,7 @@ public function test_get_key_source_reports_base64_form() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_get_key_source_reports_legacy_form() { + public function test_get_key_source_reports_legacy_form(): void { define( 'WP_SECRETS_KEY', 'not-base64-32!!' ); $provider = new WP_Secrets_Config_Key_Provider(); @@ -252,7 +277,7 @@ public function test_get_key_source_reports_legacy_form() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_use_previous_key_reads_the_previous_constant() { + 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 ); @@ -267,7 +292,7 @@ public function test_use_previous_key_reads_the_previous_constant() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_previous_key_constant_does_not_leak_into_the_current_key_path() { + 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.' ); } @@ -285,7 +310,7 @@ public function test_previous_key_constant_does_not_leak_into_the_current_key_pa * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_use_previous_key_without_the_constant_defined_is_unavailable() { + 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' ); @@ -300,7 +325,7 @@ public function test_use_previous_key_without_the_constant_defined_is_unavailabl * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_old_and_new_key_providers_are_not_interchangeable() { + 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 ) ) ); @@ -308,6 +333,7 @@ public function test_old_and_new_key_providers_are_not_interchangeable() { $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 ) ); diff --git a/tests/phpunit/tests/secrets/wpSecretsKeyManager.php b/tests/phpunit/tests/secrets/wpSecretsKeyManager.php index 0a763ed22812d..3b55b31757f1e 100644 --- a/tests/phpunit/tests/secrets/wpSecretsKeyManager.php +++ b/tests/phpunit/tests/secrets/wpSecretsKeyManager.php @@ -6,7 +6,7 @@ */ class Tests_Secrets_WPSecretsKeyManager extends WP_UnitTestCase { - public function test_get_root_key_generates_and_persists_one() { + 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 ) ); @@ -18,13 +18,13 @@ public function test_get_root_key_generates_and_persists_one() { $this->assertNotFalse( get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ) ); } - public function test_get_root_key_is_stable_on_the_same_instance() { + 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() { + public function test_get_root_key_persists_across_instances(): void { $first = new WP_Secrets_Key_Manager(); $root_a = $first->get_root_key(); @@ -34,7 +34,7 @@ public function test_get_root_key_persists_across_instances() { $this->assertSame( $root_a, $root_b ); } - public function test_get_root_key_uses_an_existing_value_rather_than_regenerating() { + 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 ); @@ -45,16 +45,17 @@ public function test_get_root_key_uses_an_existing_value_rather_than_regeneratin $this->assertSame( $known, $manager->get_root_key() ); } - public function test_get_master_key_matches_manual_derivation() { + 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() { + public function test_get_master_key_differs_per_site_id(): void { $manager = new WP_Secrets_Key_Manager(); $this->assertNotSame( @@ -63,7 +64,7 @@ public function test_get_master_key_differs_per_site_id() { ); } - public function test_get_master_key_site_and_network_scope_differ() { + public function test_get_master_key_site_and_network_scope_differ(): void { $manager = new WP_Secrets_Key_Manager(); $this->assertNotSame( @@ -72,7 +73,7 @@ public function test_get_master_key_site_and_network_scope_differ() { ); } - public function test_get_master_key_network_scope_is_stable() { + public function test_get_master_key_network_scope_is_stable(): void { $manager = new WP_Secrets_Key_Manager(); $this->assertSame( @@ -81,7 +82,7 @@ public function test_get_master_key_network_scope_is_stable() { ); } - public function test_get_master_key_defaults_site_id_to_the_current_blog() { + public function test_get_master_key_defaults_site_id_to_the_current_blog(): void { $manager = new WP_Secrets_Key_Manager(); $this->assertSame( @@ -90,7 +91,7 @@ public function test_get_master_key_defaults_site_id_to_the_current_blog() { ); } - public function test_get_master_key_reports_an_invalid_scope_as_a_wp_error() { + 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' ); @@ -103,19 +104,24 @@ public function test_get_master_key_reports_an_invalid_scope_as_a_wp_error() { /** * @dataProvider data_invalid_site_ids + * + * @param int|string $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 ) { + 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 ); + $result = $manager->get_master_key( 'site', $site_id ); // @phpstan-ignore argument.type (The data provider intentionally includes a non-integer site id.) $this->assertWPError( $result ); $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); } - public function data_invalid_site_ids() { + /** + * @return array + */ + public function data_invalid_site_ids(): array { return array( 'zero' => array( 0 ), 'negative' => array( -1 ), @@ -130,7 +136,7 @@ public function data_invalid_site_ids() { * 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() { + 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.' ); } @@ -151,7 +157,7 @@ public function test_network_scope_master_key_is_identical_across_blogs() { * 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() { + public function test_site_scope_master_key_differs_across_blogs(): void { if ( ! is_multisite() ) { $this->markTestSkipped( 'Requires multisite: proves per-blog cryptographic separation.' ); } @@ -176,7 +182,7 @@ public function test_site_scope_master_key_differs_across_blogs() { * 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() { + 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.' ); } @@ -193,6 +199,8 @@ public function test_get_root_key_adopts_a_pre_conversion_root_key() { $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 ) ); @@ -202,7 +210,7 @@ public function test_get_root_key_adopts_a_pre_conversion_root_key() { * 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() { + public function test_secret_written_before_conversion_still_decrypts(): void { if ( ! is_multisite() ) { $this->markTestSkipped( 'Requires multisite: proves secrets survive conversion.' ); } @@ -218,6 +226,7 @@ public function test_secret_written_before_conversion_still_decrypts() { $secret = wp_get_secret( 'secrets-api/pre-conversion' ); $this->assertNotWPError( $secret ); + $this->assertInstanceOf( WP_Secret::class, $secret ); $this->assertSame( 'shh', $secret->reveal() ); } @@ -225,7 +234,7 @@ public function test_secret_written_before_conversion_still_decrypts() { * 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() { + 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.' ); } @@ -252,7 +261,7 @@ public function test_rotate_site_key_works_after_conversion_before_any_read() { $this->assertSame( $master_before, $master_after ); } - public function test_rotate_fails_when_no_root_key_exists() { + public function test_rotate_fails_when_no_root_key_exists(): void { $manager = new WP_Secrets_Key_Manager(); $keyring = new WP_Secrets_Config_Key_Provider(); @@ -262,18 +271,31 @@ public function test_rotate_fails_when_no_root_key_exists() { $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code() ); } - public function test_rotate_fails_when_the_old_keyring_is_wrong() { + 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( $key_material ) { return base64_encode( $key_material ); } + + /** + * @param string $wrapped Wrapped value. + * @return WP_Error + */ public function unwrap( $wrapped ) { return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, 'wrong key' ); } + + /** + * @return string + */ public function get_key_source() { return 'test double'; } @@ -293,7 +315,7 @@ public function get_key_source() { * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_rotation_does_not_change_any_derived_master_key() { + 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 ) ) ); @@ -321,7 +343,7 @@ public function test_rotation_does_not_change_any_derived_master_key() { $this->assertWPError( $fresh_manager_under_old_key->get_root_key() ); } - public function test_unwrap_is_called_once_across_repeated_master_key_derivations() { + 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 ) ); @@ -329,8 +351,13 @@ public function test_unwrap_is_called_once_across_repeated_master_key_derivation $manager = new WP_Secrets_Key_Manager( $mock ); for ( $i = 0; $i < 5; $i++ ) { - $this->assertSame( 32, strlen( $manager->get_master_key( 'site', $i + 1 ) ) ); - $this->assertSame( 32, strlen( $manager->get_master_key( 'network' ) ) ); + $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() ); @@ -340,7 +367,7 @@ public function test_unwrap_is_called_once_across_repeated_master_key_derivation * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_unwrap_is_called_once_across_many_secret_reads() { + 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 ) ); @@ -358,7 +385,7 @@ public function test_unwrap_is_called_once_across_many_secret_reads() { $this->assertSame( 1, $mock->unwrap_call_count() ); } - public function test_rotate_site_key_updates_the_cache_without_another_unwrap() { + 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 ); @@ -374,7 +401,7 @@ public function test_rotate_site_key_updates_the_cache_without_another_unwrap() $this->assertSame( 0, $second_mock->unwrap_call_count() ); } - public function test_a_changed_wrapped_value_is_unwrapped_again_rather_than_served_from_cache() { + 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 ); @@ -389,24 +416,30 @@ public function test_a_changed_wrapped_value_is_unwrapped_again_rather_than_serv $this->assertSame( 2, $mock->unwrap_call_count() ); } - public function test_an_unwrap_error_is_not_cached() { + 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 ); - $this->assertWPError( $manager->get_root_key() ); + $while_failing = $manager->get_root_key(); + $this->assertWPError( $while_failing ); $mock->configure_fail_unwrap( false ); - $this->assertSame( $root, $manager->get_root_key() ); + $after_recovery = $manager->get_root_key(); + $this->assertSame( $root, $after_recovery ); - $this->assertSame( $root, $manager->get_root_key() ); + $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() { + public function test_generate_root_key_primes_the_cache(): void { $mock = new Mock_Keyring(); $manager = new WP_Secrets_Key_Manager( $mock ); @@ -417,7 +450,7 @@ public function test_generate_root_key_primes_the_cache() { $this->assertSame( 1, $mock->wrap_call_count() ); } - public function test_the_returned_root_key_is_a_copy_the_caller_can_zero() { + 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 ) ); @@ -425,6 +458,7 @@ public function test_the_returned_root_key_is_a_copy_the_caller_can_zero() { $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() ); diff --git a/tests/phpunit/tests/secrets/wpSecretsMemzero.php b/tests/phpunit/tests/secrets/wpSecretsMemzero.php index 3bebe25d201c9..18a6b0f6930e7 100644 --- a/tests/phpunit/tests/secrets/wpSecretsMemzero.php +++ b/tests/phpunit/tests/secrets/wpSecretsMemzero.php @@ -4,7 +4,7 @@ */ class Tests_Secrets_WpSecretsMemzero extends WP_UnitTestCase { - public function test_clears_a_plaintext_string() { + public function test_clears_a_plaintext_string(): void { $value = 'a plaintext value'; wp_secrets_memzero( $value ); @@ -13,15 +13,15 @@ public function test_clears_a_plaintext_string() { $this->assertSame( '', $value ); } - public function test_leaves_non_string_values_untouched() { + public function test_leaves_non_string_values_untouched(): void { $value = 42; - wp_secrets_memzero( $value ); + wp_secrets_memzero( $value ); // @phpstan-ignore argument.type (Intentionally passing a non-string value.) - $this->assertSame( 42, $value ); + $this->assertSame( 42, $value ); // @phpstan-ignore method.impossibleType (The by-reference parameter is documented as a string; this checks a non-string comes back untouched.) } - public function test_leaves_an_empty_string_untouched() { + public function test_leaves_an_empty_string_untouched(): void { $value = ''; wp_secrets_memzero( $value ); @@ -29,7 +29,7 @@ public function test_leaves_an_empty_string_untouched() { $this->assertSame( '', $value ); } - public function test_does_not_throw_when_the_value_is_shared() { + public function test_does_not_throw_when_the_value_is_shared(): void { $value = 'shared plaintext'; $copy = $value; @@ -38,6 +38,6 @@ public function test_does_not_throw_when_the_value_is_shared() { $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 ); + $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.) } } diff --git a/tests/phpunit/tests/secrets/wpSecretsOptionStore.php b/tests/phpunit/tests/secrets/wpSecretsOptionStore.php index f39ae6a10a045..e191149d70d60 100644 --- a/tests/phpunit/tests/secrets/wpSecretsOptionStore.php +++ b/tests/phpunit/tests/secrets/wpSecretsOptionStore.php @@ -6,7 +6,12 @@ */ class Tests_Secrets_WPSecretsOptionStore extends WP_UnitTestCase { - private function sample_record() { + /** + * A well-formed single-slot record. + * + * @return array{v: int, current: array} + */ + private function sample_record(): array { return array( 'v' => 1, 'current' => array( @@ -20,17 +25,17 @@ private function sample_record() { ); } - public function test_implements_the_store_interface() { - $this->assertInstanceOf( WP_Secrets_Store::class, new WP_Secrets_Option_Store() ); + 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() { + 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() { + public function test_set_then_get_round_trips_a_record_site_scope(): void { $store = new WP_Secrets_Option_Store(); $record = $this->sample_record(); @@ -38,7 +43,7 @@ public function test_set_then_get_round_trips_a_record_site_scope() { $this->assertSame( $record, $store->get( 'myplugin/key' ) ); } - public function test_set_then_get_round_trips_a_record_network_scope() { + public function test_set_then_get_round_trips_a_record_network_scope(): void { $store = new WP_Secrets_Option_Store(); $record = $this->sample_record(); @@ -46,7 +51,7 @@ public function test_set_then_get_round_trips_a_record_network_scope() { $this->assertSame( $record, $store->get( 'myplugin/key', true ) ); } - public function test_site_and_network_scope_do_not_collide() { + 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' ) ); @@ -58,16 +63,16 @@ public function test_site_and_network_scope_do_not_collide() { $this->assertSame( $network_record, $store->get( 'myplugin/key', true ) ); } - public function test_set_rejects_a_non_array_record() { + public function test_set_rejects_a_non_array_record(): void { $store = new WP_Secrets_Option_Store(); - $result = $store->set( 'myplugin/key', 'not an array' ); + $result = $store->set( 'myplugin/key', 'not an array' ); // @phpstan-ignore argument.type (Intentionally passing a non-array record to exercise the runtime guard.) $this->assertWPError( $result ); $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() ); } - public function test_get_returns_an_error_when_the_stored_value_is_not_an_array() { + 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(); @@ -78,7 +83,7 @@ public function test_get_returns_an_error_when_the_stored_value_is_not_an_array( $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() ); } - public function test_delete_removes_an_existing_record() { + public function test_delete_removes_an_existing_record(): void { $store = new WP_Secrets_Option_Store(); $store->set( 'myplugin/key', $this->sample_record() ); @@ -86,13 +91,16 @@ public function test_delete_removes_an_existing_record() { $this->assertNull( $store->get( 'myplugin/key' ) ); } - public function test_delete_on_an_absent_record_is_not_an_error() { + 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' ) ); } - public function test_site_scope_records_are_not_autoloaded() { + /** + * @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(); @@ -108,35 +116,44 @@ public function test_site_scope_records_are_not_autoloaded() { $this->assertContains( $autoload, array( 'no', 'off' ) ); } - public function test_list_names_returns_stored_names_without_the_prefix() { + 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() { + 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() ); - $this->assertNotContains( 'completely_unrelated_option', $store->list_names() ); + $names = $store->list_names(); + + $this->assertIsArray( $names ); + $this->assertNotContains( 'completely_unrelated_option', $names ); } - public function test_list_names_does_not_include_network_scope_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 ); - $this->assertContains( 'myplugin/site-only', $store->list_names( false ) ); - $this->assertNotContains( 'myplugin/network-only', $store->list_names( false ) ); - $this->assertContains( 'myplugin/network-only', $store->list_names( true ) ); - $this->assertNotContains( 'myplugin/site-only', $store->list_names( 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 ); } /** @@ -144,7 +161,7 @@ public function test_list_names_does_not_include_network_scope_names() { * 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() { + 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 ); @@ -154,6 +171,7 @@ public function test_list_names_escapes_the_underscore_wildcard() { $names = $store->list_names(); + $this->assertIsArray( $names ); $this->assertContains( 'myplugin/real', $names ); $this->assertNotContains( 'decoy/name', $names ); } diff --git a/tests/phpunit/tests/secrets/wpSecretsValidateName.php b/tests/phpunit/tests/secrets/wpSecretsValidateName.php index 736741c143c80..ba3303b1cd6d4 100644 --- a/tests/phpunit/tests/secrets/wpSecretsValidateName.php +++ b/tests/phpunit/tests/secrets/wpSecretsValidateName.php @@ -6,12 +6,17 @@ 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 ) { + public function test_accepts_valid_names( $name ): void { $this->assertTrue( wp_secrets_validate_name( $name ) ); } - public function data_valid_names() { + /** + * @return array + */ + public function data_valid_names(): array { return array( 'simple' => array( 'myplugin/api-key' ), 'single character segments' => array( 'a/b' ), @@ -26,9 +31,11 @@ public function data_valid_names() { /** * @dataProvider data_invalid_names + * + * @param mixed $name A value that must be rejected; not always a string. */ - public function test_rejects_invalid_names( $name ) { - $result = wp_secrets_validate_name( $name ); + public function test_rejects_invalid_names( $name ): void { + $result = wp_secrets_validate_name( $name ); // @phpstan-ignore argument.type (The data provider intentionally includes non-string values.) $this->assertWPError( $result ); $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); @@ -41,14 +48,19 @@ public function test_rejects_invalid_names( $name ) { * 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 ) { + 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 ) ); } - public function data_unnamespaced_names() { + /** + * @return array + */ + public function data_unnamespaced_names(): array { return array( 'simple' => array( 'api_key' ), 'with hyphen' => array( 'api-key' ), @@ -62,14 +74,17 @@ public function data_unnamespaced_names() { * 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() { + 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() ); } - public function data_invalid_names() { + /** + * @return array + */ + public function data_invalid_names(): array { return array( 'empty string' => array( '' ), 'two slashes' => array( 'too/many/slashes' ), diff --git a/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php b/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php index 41d56fe73563d..ebd4820f20b8b 100644 --- a/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php +++ b/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php @@ -8,7 +8,7 @@ class Tests_Secrets_WpSetSecretAndWpGetSecret extends WP_UnitTestCase { use WP_Secrets_Assertions; - public function test_set_then_get_round_trips() { + 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' ); @@ -19,20 +19,22 @@ public function test_set_then_get_round_trips() { $this->assertNotSame( '', $secret->fingerprint() ); } - public function test_get_returns_null_for_an_absent_secret() { + 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() { + public function test_get_defaults_to_the_current_version(): void { wp_set_secret( 'myplugin/api-key', 'value' ); - $this->assertSame( - wp_get_secret( 'myplugin/api-key' )->reveal(), - wp_get_secret( 'myplugin/api-key', WP_Secret_Version::CURRENT )->reveal() - ); + $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() { + 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 ) ); @@ -44,7 +46,7 @@ public function test_get_previous_on_a_never_rotated_secret_is_null_not_error() * 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() { + 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' ); @@ -53,41 +55,49 @@ public function test_an_invalid_version_is_a_wp_error_not_an_exception() { $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); } - public function test_set_rejects_an_invalid_name() { + 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() { + 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() { - $result = wp_set_secret( 'myplugin/api-key', array( 'not', 'a', 'string' ) ); + 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() { + public function test_set_accepts_an_empty_string_value(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', '' ) ); - $this->assertSame( '', wp_get_secret( 'myplugin/api-key' )->reveal() ); + + $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() { + 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' ) ); - $this->assertSame( 'a fresh value', wp_get_secret( 'myplugin/corrupt' )->reveal() ); + + $secret = wp_get_secret( 'myplugin/corrupt' ); + + $this->assertInstanceOf( WP_Secret::class, $secret ); + $this->assertSame( 'a fresh value', $secret->reveal() ); } /** @@ -95,9 +105,11 @@ public function test_a_corrupt_record_can_be_overwritten() { * 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() { + 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( @@ -114,20 +126,23 @@ public function test_the_options_table_contains_no_plaintext_after_a_write() { * 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() { + public function test_reveal_still_works_after_the_internal_memzero(): void { wp_set_secret( 'myplugin/api-key', 'must-survive-memzero' ); - $this->assertSame( 'must-survive-memzero', wp_get_secret( 'myplugin/api-key' )->reveal() ); + $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() { + 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() { + public function test_change_hook_fires_on_create_with_created_action(): void { $captured = null; add_action( 'wp_secret_changed', @@ -149,9 +164,12 @@ function ( ...$args ) use ( &$captured ) { $this->assertNotSame( '', $new_fingerprint ); } - public function test_change_hook_fires_on_overwrite_with_updated_action() { + public function test_change_hook_fires_on_overwrite_with_updated_action(): void { wp_set_secret( 'myplugin/api-key', 'first-value' ); - $first_fingerprint = wp_get_secret( 'myplugin/api-key' )->fingerprint(); + $first = wp_get_secret( 'myplugin/api-key' ); + + $this->assertInstanceOf( WP_Secret::class, $first ); + $first_fingerprint = $first->fingerprint(); $captured = null; add_action( @@ -165,6 +183,7 @@ function ( ...$args ) use ( &$captured ) { wp_set_secret( 'myplugin/api-key', 'second-value' ); + $this->assertIsArray( $captured ); list( , $action, , , $old_fingerprint, $new_fingerprint ) = $captured; $this->assertSame( 'updated', $action ); @@ -172,7 +191,7 @@ function ( ...$args ) use ( &$captured ) { $this->assertNotSame( $old_fingerprint, $new_fingerprint ); } - public function test_change_hook_reports_the_current_user_as_actor() { + public function test_change_hook_reports_the_current_user_as_actor(): void { $user_id = self::factory()->user->create(); wp_set_current_user( $user_id ); @@ -191,7 +210,7 @@ function ( $name, $action, $actor_id ) use ( &$captured_actor ) { $this->assertSame( $user_id, $captured_actor ); } - public function test_change_hook_never_receives_a_value() { + public function test_change_hook_never_receives_a_value(): void { $captured_args = null; add_action( 'wp_secret_changed', @@ -204,6 +223,8 @@ function ( ...$args ) use ( &$captured_args ) { 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 ); From 1d82c8e89c823cac09a788afdbde4991c4e5b8dd Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Wed, 7 Oct 2026 13:15:28 -0700 Subject: [PATCH 08/12] Secrets API: omit the text domain from translation calls Core does not pass 'default' to __() and its relatives for its own strings. The 101 calls in the Secrets API now leave the argument off, as the rest of core does. The strings are unchanged. See #66187. --- src/wp-admin/includes/secrets-site-health.php | 77 +++++++++---------- src/wp-includes/secrets.php | 28 +++---- .../class-wp-secrets-broken-keyring.php | 4 +- .../class-wp-secrets-broken-provider.php | 4 +- .../secrets/class-wp-secrets-broken-store.php | 2 +- .../secrets/class-wp-secrets-cipher.php | 32 ++++---- .../class-wp-secrets-config-key-provider.php | 18 ++--- .../secrets/class-wp-secrets-key-manager.php | 14 ++-- .../class-wp-secrets-libsodium-provider.php | 10 +-- .../secrets/class-wp-secrets-option-store.php | 10 +-- 10 files changed, 99 insertions(+), 100 deletions(-) diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index f41692dfbd155..fab1be4bad7e0 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -21,17 +21,17 @@ */ function wp_secrets_site_health_tests( $tests ) { $tests['direct']['secrets_api_key_source'] = array( - 'label' => __( 'Secrets API key source', 'default' ), + 'label' => __( 'Secrets API key source' ), 'test' => 'wp_secrets_site_health_test_key_source', ); $tests['direct']['secrets_api_undecryptable'] = array( - 'label' => __( 'Secrets API: undecryptable secrets', 'default' ), + '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', 'default' ), + 'label' => __( 'Secrets API: credentials pending rotation' ), 'test' => 'wp_secrets_site_health_test_needs_rotation', ); @@ -65,7 +65,7 @@ function wp_secrets_site_health_result( $test, $label, $status, $description ) { 'label' => $label, 'status' => $status, 'badge' => array( - 'label' => __( 'Security', 'default' ), + 'label' => __( 'Security' ), 'color' => isset( $colors[ $status ] ) ? $colors[ $status ] : 'blue', ), 'description' => $description, @@ -87,11 +87,11 @@ function wp_secrets_site_health_test_key_source() { if ( false !== strpos( $key_source, 'broken' ) ) { return wp_secrets_site_health_result( 'secrets_api_key_source', - __( 'Secrets API key source is broken', 'default' ), + __( 'Secrets API key source is broken' ), 'critical', sprintf( '

    %s

    ', - esc_html__( 'The active keyring did not load correctly. No secret can be encrypted or decrypted until this is fixed.', 'default' ) + esc_html__( 'The active keyring did not load correctly. No secret can be encrypted or decrypted until this is fixed.' ) ) ); } @@ -99,11 +99,11 @@ function wp_secrets_site_health_test_key_source() { if ( false !== strpos( $key_source, 'legacy interpretation' ) ) { return wp_secrets_site_health_result( 'secrets_api_key_source', - __( 'Secrets API key should be regenerated', 'default' ), + __( 'Secrets API key should be regenerated' ), 'recommended', sprintf( '

    %s

    ', - esc_html__( 'WP_SECRETS_KEY is defined, but not in the recommended base64-encoded 32-byte form. Regenerate it with `wp secret generate-key`.', 'default' ) + esc_html__( 'WP_SECRETS_KEY is defined, but not in the recommended base64-encoded 32-byte form. Regenerate it with `wp secret generate-key`.' ) ) ); } @@ -111,18 +111,18 @@ function wp_secrets_site_health_test_key_source() { 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', 'default' ), + __( 'Secrets API is using a fallback key' ), 'recommended', sprintf( '

    %s

    ', - 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`.', 'default' ) + 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', 'default' ), + __( 'Secrets API is using a dedicated key' ), 'good', sprintf( '

    %s

    ', esc_html( $key_source ) ) ); @@ -150,9 +150,9 @@ function wp_secrets_site_health_test_undecryptable() { if ( empty( $broken ) ) { return wp_secrets_site_health_result( 'secrets_api_undecryptable', - __( 'All secrets can be decrypted', 'default' ), + __( 'All secrets can be decrypted' ), 'good', - sprintf( '

    %s

    ', esc_html__( 'Every stored secret decrypted successfully.', 'default' ) ) + sprintf( '

    %s

    ', esc_html__( 'Every stored secret decrypted successfully.' ) ) ); } @@ -164,7 +164,7 @@ function wp_secrets_site_health_test_undecryptable() { esc_html( $entry['name'] ), sprintf( /* translators: %s: A secret's last known fingerprint. */ - esc_html__( 'fingerprint: %s', 'default' ), + esc_html__( 'fingerprint: %s' ), esc_html( $entry['fingerprint'] ) ) ); @@ -172,13 +172,13 @@ function wp_secrets_site_health_test_undecryptable() { return wp_secrets_site_health_result( 'secrets_api_undecryptable', - __( 'Some secrets cannot be decrypted', 'default' ), + __( 'Some secrets cannot be decrypted' ), 'critical', sprintf( '

    %s

      %s

    %s

    ', - 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.', 'default' ), + 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.', 'default' ) + esc_html__( 'This can happen after losing WP_SECRETS_KEY, restoring a database backup without its matching key, or a failed key rotation.' ) ) ); } @@ -235,15 +235,15 @@ function wp_secrets_site_health_test_needs_rotation() { if ( 0 === $count ) { return wp_secrets_site_health_result( 'secrets_api_needs_rotation', - __( 'No secrets are pending rotation', 'default' ), + __( 'No secrets are pending rotation' ), 'good', - sprintf( '

    %s

    ', esc_html__( 'No secret is flagged as needing rotation.', 'default' ) ) + sprintf( '

    %s

    ', esc_html__( 'No secret is flagged as needing rotation.' ) ) ); } return wp_secrets_site_health_result( 'secrets_api_needs_rotation', - __( 'Some secrets are pending rotation', 'default' ), + __( 'Some secrets are pending rotation' ), 'recommended', sprintf( '

    %s

    ', @@ -253,8 +253,7 @@ function wp_secrets_site_health_test_needs_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, - 'default' + $count ), $count ) @@ -306,57 +305,57 @@ function wp_secrets_site_health_debug_info( $info ) { $fields = array( 'dropin_active' => array( - 'label' => __( 'Drop-in active', 'default' ), - 'value' => wp_using_secrets_dropin() ? __( 'Yes', 'default' ) : __( 'No', 'default' ), + 'label' => __( 'Drop-in active' ), + 'value' => wp_using_secrets_dropin() ? __( 'Yes' ) : __( 'No' ), ), 'provider_class' => array( - 'label' => __( 'Provider class', 'default' ), + 'label' => __( 'Provider class' ), 'value' => get_class( $provider ), ), 'protected_by' => array( - 'label' => __( 'Secrets protected by', 'default' ), + 'label' => __( 'Secrets protected by' ), 'value' => $provider->get_label(), ), 'protection_at' => array( - 'label' => __( 'Encryption boundary', 'default' ), + 'label' => __( 'Encryption boundary' ), 'value' => WP_Secrets_Provider::BOUNDARY_WORDPRESS === $provider->get_protection_boundary() - ? __( 'WordPress', 'default' ) - : __( 'The provider (outside WordPress)', 'default' ), + ? __( 'WordPress' ) + : __( 'The provider (outside WordPress)' ), ), 'writable' => array( - 'label' => __( 'Accepts writes', 'default' ), - 'value' => $provider->is_writable() ? __( 'Yes', 'default' ) : __( 'No', 'default' ), + 'label' => __( 'Accepts writes' ), + 'value' => $provider->is_writable() ? __( 'Yes' ) : __( 'No' ), ), 'keyring_class' => array( - 'label' => __( 'Keyring class', 'default' ), + 'label' => __( 'Keyring class' ), 'value' => get_class( $key_manager->get_keyring() ), ), 'key_source' => array( - 'label' => __( 'Key source', 'default' ), + 'label' => __( 'Key source' ), 'value' => $key_manager->get_keyring()->get_key_source(), ), 'record_version' => array( - 'label' => __( 'Record format version', 'default' ), + 'label' => __( 'Record format version' ), 'value' => (string) WP_SECRETS_RECORD_VERSION, ), ); $site_secrets = wp_list_secrets(); $fields['site_secret_count'] = array( - 'label' => __( 'Site secrets', 'default' ), - 'value' => is_wp_error( $site_secrets ) ? __( 'Unavailable', 'default' ) : (string) count( $site_secrets ), + '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', 'default' ), - 'value' => is_wp_error( $network_secrets ) ? __( 'Unavailable', 'default' ) : (string) count( $network_secrets ), + 'label' => __( 'Network secrets' ), + 'value' => is_wp_error( $network_secrets ) ? __( 'Unavailable' ) : (string) count( $network_secrets ), ); } $info['secrets-api'] = array( - 'label' => __( 'Secrets API', 'default' ), + 'label' => __( 'Secrets API' ), 'fields' => $fields, ); diff --git a/src/wp-includes/secrets.php b/src/wp-includes/secrets.php index dfe10e6dd9e25..18a809c27e557 100644 --- a/src/wp-includes/secrets.php +++ b/src/wp-includes/secrets.php @@ -226,7 +226,7 @@ function wp_secrets_validate_name( $name ) { if ( ! is_string( $name ) || '' === $name ) { return new WP_Error( WP_SECRETS_ERROR_INVALID_NAME, - __( 'Secret names must be non-empty strings.', 'default' ) + __( 'Secret names must be non-empty strings.' ) ); } @@ -235,7 +235,7 @@ function wp_secrets_validate_name( $name ) { WP_SECRETS_ERROR_INVALID_NAME, sprintf( /* translators: %d: Maximum allowed length, in characters. */ - __( 'Secret names must be %d characters or fewer.', 'default' ), + __( 'Secret names must be %d characters or fewer.' ), WP_SECRETS_MAX_NAME_LENGTH ) ); @@ -246,7 +246,7 @@ function wp_secrets_validate_name( $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.', 'default' ) + __( 'Secret names may contain at most one "/", separating a namespace from a key.' ) ); } @@ -256,7 +256,7 @@ function wp_secrets_validate_name( $name ) { 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.', 'default' ) + __( 'Secret names may contain only lowercase letters, numbers, hyphens, and underscores, and must not start or end with a hyphen or underscore.' ) ); } @@ -269,7 +269,7 @@ function wp_secrets_validate_name( $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.', 'default' ), + __( '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' @@ -285,14 +285,14 @@ function wp_secrets_validate_name( $name ) { if ( '' === $namespace || '' === $key ) { return new WP_Error( WP_SECRETS_ERROR_INVALID_NAME, - __( 'Both segments of a secret name must be non-empty.', 'default' ) + __( '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.', 'default' ) + __( 'Secret name segments may contain only lowercase letters, numbers, hyphens, and underscores, and must not start or end with a hyphen or underscore.' ) ); } @@ -452,7 +452,7 @@ function wp_import_option_as_secret( $option, $name ) { if ( ! is_string( $option ) || '' === $option ) { return new WP_Error( WP_SECRETS_ERROR_INVALID_VALUE, - __( 'Option name must be a non-empty string.', 'default' ) + __( 'Option name must be a non-empty string.' ) ); } @@ -461,14 +461,14 @@ function wp_import_option_as_secret( $option, $name ) { if ( null === $value ) { return new WP_Error( WP_SECRETS_ERROR_INVALID_VALUE, - __( 'The option does not exist.', 'default' ) + __( '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.', 'default' ) + __( 'The option value is not a string and cannot be imported as a secret.' ) ); } @@ -745,13 +745,13 @@ function _wp_secrets_get( $name, $version, $network ) { 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.', 'default' ), + __( '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.', 'default' ) + __( 'The version must be WP_Secret_Version::CURRENT or WP_Secret_Version::PREVIOUS.' ) ); } @@ -800,13 +800,13 @@ function _wp_secrets_list( $name_prefix, $network ) { if ( ! is_string( $name_prefix ) ) { _doing_it_wrong( __FUNCTION__, - __( 'The namespace must be a string.', 'default' ), + __( 'The namespace must be a string.' ), '7.2.0' ); return new WP_Error( WP_SECRETS_ERROR_INVALID_ARGUMENT, - __( 'The namespace must be a string.', 'default' ) + __( 'The namespace must be a string.' ) ); } diff --git a/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php b/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php index abcab9980e71e..b0da8b44e3ab0 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php @@ -54,7 +54,7 @@ public function unwrap( $wrapped ) { * @return string */ public function get_key_source() { - return __( 'broken secrets.php drop-in', 'default' ); + return __( 'broken secrets.php drop-in' ); } /** @@ -67,7 +67,7 @@ public function get_key_source() { private function error() { return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, - __( 'The secrets.php drop-in did not provide a usable keyring.', 'default' ) + __( 'The secrets.php drop-in did not provide a usable keyring.' ) ); } } diff --git a/src/wp-includes/secrets/class-wp-secrets-broken-provider.php b/src/wp-includes/secrets/class-wp-secrets-broken-provider.php index 48ebf63823f8d..5462c1aac49b6 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-provider.php @@ -34,7 +34,7 @@ final class WP_Secrets_Broken_Provider implements WP_Secrets_Provider { private function 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.', 'default' ) + __( 'The secrets.php drop-in did not load correctly, so no secret can be read or written. Fix or remove the drop-in.' ) ); } @@ -115,7 +115,7 @@ public function list_secrets( $name_prefix = '', $network = false ) { * @return string */ public function get_label() { - return __( 'Unavailable: the secrets.php drop-in did not load correctly', 'default' ); + return __( 'Unavailable: the secrets.php drop-in did not load correctly' ); } /** diff --git a/src/wp-includes/secrets/class-wp-secrets-broken-store.php b/src/wp-includes/secrets/class-wp-secrets-broken-store.php index 4e06312a7c556..135f5dfa94591 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-store.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-store.php @@ -86,7 +86,7 @@ public function list_names( $network = false ) { private function error() { return new WP_Error( WP_SECRETS_ERROR_STORE_UNAVAILABLE, - __( 'The secrets.php drop-in did not provide a usable store.', 'default' ) + __( 'The secrets.php drop-in did not provide a usable store.' ) ); } } diff --git a/src/wp-includes/secrets/class-wp-secrets-cipher.php b/src/wp-includes/secrets/class-wp-secrets-cipher.php index 8579c18f3ae38..6ed90c7b155db 100644 --- a/src/wp-includes/secrets/class-wp-secrets-cipher.php +++ b/src/wp-includes/secrets/class-wp-secrets-cipher.php @@ -77,14 +77,14 @@ public function encrypt_value( $master_key, $scope, $site_id, $name, $slot, $pla if ( ! is_string( $plaintext ) ) { return new WP_Error( WP_SECRETS_ERROR_INVALID_VALUE, - __( 'Secret values must be strings.', 'default' ) + __( '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.', 'default' ) + __( 'No libsodium implementation is available.' ) ); } @@ -153,7 +153,7 @@ public function decrypt_value( $master_key, $scope, $site_id, $name, $slot, $rec if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_decrypt' ) ) { return new WP_Error( WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE, - __( 'No libsodium implementation is available.', 'default' ) + __( 'No libsodium implementation is available.' ) ); } @@ -167,7 +167,7 @@ public function decrypt_value( $master_key, $scope, $site_id, $name, $slot, $rec if ( ! is_string( $data_key ) ) { return new WP_Error( WP_SECRETS_ERROR_DECRYPTION_FAILED, - __( "The secret's data key could not be decrypted.", 'default' ) + __( "The secret's data key could not be decrypted." ) ); } @@ -183,7 +183,7 @@ public function decrypt_value( $master_key, $scope, $site_id, $name, $slot, $rec if ( ! is_string( $plaintext ) ) { return new WP_Error( WP_SECRETS_ERROR_DECRYPTION_FAILED, - __( 'The secret value could not be decrypted.', 'default' ) + __( 'The secret value could not be decrypted.' ) ); } @@ -210,21 +210,21 @@ public function fingerprint( $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.', 'default' ) + __( '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.', 'default' ) + __( '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.', 'default' ) + __( 'No libsodium implementation is available.' ) ); } @@ -283,21 +283,21 @@ private function build_aad( $purpose, $scope, $site_id, $name, $slot ) { */ private function validate_common( $master_key, $scope, $site_id, $name, $slot ) { if ( ! in_array( $scope, array( 'site', 'network' ), true ) ) { - return $this->invalid_argument( __( 'The scope must be "site" or "network".', 'default' ) ); + return $this->invalid_argument( __( 'The scope must be "site" or "network".' ) ); } if ( ! is_int( $site_id ) || $site_id < 0 ) { - return $this->invalid_argument( __( 'The site id must be a non-negative integer.', 'default' ) ); + 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.', 'default' ) ); + 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.', 'default' ) + __( 'A 32-byte master key is required.' ) ); } @@ -351,7 +351,7 @@ 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.', 'default' ) + __( 'Secret record slot must be an array.' ) ); } @@ -363,7 +363,7 @@ private function decode_record_fields( $record ) { WP_SECRETS_ERROR_RECORD_MALFORMED, sprintf( /* translators: %s: Record field name. */ - __( 'Secret record slot is missing the "%s" field.', 'default' ), + __( 'Secret record slot is missing the "%s" field.' ), $field ) ); @@ -376,7 +376,7 @@ private function decode_record_fields( $record ) { WP_SECRETS_ERROR_RECORD_MALFORMED, sprintf( /* translators: %s: Record field name. */ - __( 'Secret record slot field "%s" is not valid base64.', 'default' ), + __( 'Secret record slot field "%s" is not valid base64.' ), $field ) ); @@ -389,7 +389,7 @@ private function decode_record_fields( $record ) { || 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.', 'default' ) + __( 'Secret record slot has a malformed nonce.' ) ); } diff --git a/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php b/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php index 3647e94bc5f98..a7ca14f7f62df 100644 --- a/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php @@ -88,14 +88,14 @@ public function wrap( $key_material ) { if ( ! is_string( $key_material ) || '' === $key_material ) { return new WP_Error( WP_SECRETS_ERROR_INVALID_VALUE, - __( 'Key material to wrap must be a non-empty string.', 'default' ) + __( 'Key material to wrap must be a non-empty string.' ) ); } if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' ) ) { return new WP_Error( WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE, - __( 'No libsodium implementation is available.', 'default' ) + __( 'No libsodium implementation is available.' ) ); } @@ -125,14 +125,14 @@ public function unwrap( $wrapped ) { if ( ! is_string( $wrapped ) || '' === $wrapped ) { return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, - __( 'Nothing to unwrap.', 'default' ) + __( '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.', 'default' ) + __( 'No libsodium implementation is available.' ) ); } @@ -142,7 +142,7 @@ public function unwrap( $wrapped ) { if ( false === $raw || strlen( $raw ) <= $nonce_length ) { return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, - __( 'The wrapped key material is malformed.', 'default' ) + __( 'The wrapped key material is malformed.' ) ); } @@ -162,7 +162,7 @@ public function unwrap( $wrapped ) { 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.', 'default' ) + __( 'The wrapped key material could not be decrypted with the configured site key.' ) ); } @@ -204,7 +204,7 @@ private function get_site_key() { WP_SECRETS_ERROR_KEY_UNAVAILABLE, sprintf( /* translators: %s: PHP constant name. */ - __( '%s is defined but is not a usable string.', 'default' ), + __( '%s is defined but is not a usable string.' ), $constant_name ) ); @@ -222,7 +222,7 @@ private function get_site_key() { if ( $this->use_previous_key ) { return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, - __( 'WP_SECRETS_KEY_PREVIOUS is not defined.', 'default' ) + __( 'WP_SECRETS_KEY_PREVIOUS is not defined.' ) ); } @@ -232,7 +232,7 @@ private function get_site_key() { 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).', 'default' ) + __( '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).' ) ); } diff --git a/src/wp-includes/secrets/class-wp-secrets-key-manager.php b/src/wp-includes/secrets/class-wp-secrets-key-manager.php index d8426f4a0ece8..8f66ad37fff74 100644 --- a/src/wp-includes/secrets/class-wp-secrets-key-manager.php +++ b/src/wp-includes/secrets/class-wp-secrets-key-manager.php @@ -145,7 +145,7 @@ public function get_keyring() { */ public function get_master_key( $scope, $site_id = null ) { if ( ! in_array( $scope, array( 'site', 'network' ), true ) ) { - $message = __( 'The scope must be "site" or "network".', 'default' ); + $message = __( 'The scope must be "site" or "network".' ); _doing_it_wrong( __METHOD__, $message, '7.2.0' ); @@ -159,7 +159,7 @@ public function get_master_key( $scope, $site_id = null ) { $subkey_id = null === $site_id ? get_current_blog_id() : $site_id; if ( ! is_int( $subkey_id ) || $subkey_id < 1 ) { - $message = __( 'The site id must be a positive integer.', 'default' ); + $message = __( 'The site id must be a positive integer.' ); _doing_it_wrong( __METHOD__, $message, '7.2.0' ); @@ -180,7 +180,7 @@ public function get_master_key( $scope, $site_id = null ) { return new WP_Error( WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE, - __( 'No libsodium implementation is available.', 'default' ) + __( 'No libsodium implementation is available.' ) ); } @@ -208,7 +208,7 @@ public function get_root_key() { if ( ! is_string( $wrapped ) ) { return new WP_Error( WP_SECRETS_ERROR_RECORD_MALFORMED, - __( 'The stored root key is not a string.', 'default' ) + __( 'The stored root key is not a string.' ) ); } @@ -245,7 +245,7 @@ public function rotate_site_key( WP_Secrets_Keyring $old_keyring, WP_Secrets_Key if ( ! is_string( $wrapped ) ) { return new WP_Error( WP_SECRETS_ERROR_STORE_UNAVAILABLE, - __( 'No root key exists to rotate.', 'default' ) + __( 'No root key exists to rotate.' ) ); } @@ -280,7 +280,7 @@ public function rotate_site_key( WP_Secrets_Keyring $old_keyring, WP_Secrets_Key return new WP_Error( WP_SECRETS_ERROR_STORE_UNAVAILABLE, - __( 'Could not store the re-wrapped root key.', 'default' ) + __( 'Could not store the re-wrapped root key.' ) ); } @@ -364,7 +364,7 @@ private function generate_root_key() { if ( ! is_string( $existing ) ) { return new WP_Error( WP_SECRETS_ERROR_STORE_UNAVAILABLE, - __( 'Could not read the root key after losing the creation race.', 'default' ) + __( 'Could not read the root key after losing the creation race.' ) ); } diff --git a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php index a1f4c5ce94ce6..6c6abb7fad054 100644 --- a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php @@ -79,7 +79,7 @@ public function get_label() { return sprintf( /* translators: %s: Human-readable description of the key source. */ - __( 'WordPress (libsodium), key source: %s', 'default' ), + __( 'WordPress (libsodium), key source: %s' ), $keyring->get_key_source() ); } @@ -123,7 +123,7 @@ 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.', 'default' ) + __( 'The stored secret record is missing required fields.' ) ); } @@ -132,7 +132,7 @@ private function validate_record_shape( $record ) { WP_SECRETS_ERROR_RECORD_UNSUPPORTED_VERSION, sprintf( /* translators: %s: Unrecognized record format version. */ - __( 'Secret record format version "%s" is not supported.', 'default' ), + __( 'Secret record format version "%s" is not supported.' ), is_scalar( $record['v'] ) ? $record['v'] : gettype( $record['v'] ) ) ); @@ -141,7 +141,7 @@ private function validate_record_shape( $record ) { if ( ! is_array( $record['current'] ) ) { return new WP_Error( WP_SECRETS_ERROR_RECORD_MALFORMED, - __( 'The stored secret record is missing required fields.', 'default' ) + __( 'The stored secret record is missing required fields.' ) ); } @@ -296,7 +296,7 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ if ( ! is_string( $value ) ) { return new WP_Error( WP_SECRETS_ERROR_INVALID_VALUE, - __( 'Secret values must be strings.', 'default' ) + __( 'Secret values must be strings.' ) ); } diff --git a/src/wp-includes/secrets/class-wp-secrets-option-store.php b/src/wp-includes/secrets/class-wp-secrets-option-store.php index 6f7051d992e5a..a4f7ef91ac910 100644 --- a/src/wp-includes/secrets/class-wp-secrets-option-store.php +++ b/src/wp-includes/secrets/class-wp-secrets-option-store.php @@ -59,7 +59,7 @@ public function get( $name, $network = false ) { if ( ! is_array( $value ) ) { return new WP_Error( WP_SECRETS_ERROR_RECORD_MALFORMED, - __( 'The stored secret record is not an array.', 'default' ) + __( 'The stored secret record is not an array.' ) ); } @@ -80,7 +80,7 @@ public function set( $name, $record, $network = false ) { if ( ! is_array( $record ) ) { return new WP_Error( WP_SECRETS_ERROR_RECORD_MALFORMED, - __( 'A secret record must be an array.', 'default' ) + __( 'A secret record must be an array.' ) ); } @@ -110,7 +110,7 @@ public function set( $name, $record, $network = false ) { return new WP_Error( WP_SECRETS_ERROR_STORE_UNAVAILABLE, - __( 'The secret record could not be written.', 'default' ) + __( 'The secret record could not be written.' ) ); } @@ -141,7 +141,7 @@ public function delete( $name, $network = false ) { return new WP_Error( WP_SECRETS_ERROR_STORE_UNAVAILABLE, - __( 'The secret record could not be deleted.', 'default' ) + __( 'The secret record could not be deleted.' ) ); } @@ -181,7 +181,7 @@ public function list_names( $network = false ) { if ( ! is_array( $option_names ) ) { return new WP_Error( WP_SECRETS_ERROR_STORE_UNAVAILABLE, - __( 'Could not list secrets.', 'default' ) + __( 'Could not list secrets.' ) ); } From e5d01a66133f43f9298702ccfbe205049995e41d Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Wed, 7 Oct 2026 13:15:53 -0700 Subject: [PATCH 09/12] Secrets API: report scope in wp_secret_changed, and test providers for firing it The provider fires wp_secret_changed, because only the provider has the prior fingerprint without another read. A drop-in provider that left the call out would silence a site's audit log, and nothing checked for that. The hook stays with the provider, and the requirement is now enforced: - WP_Secrets_Provider states it on delete() and retire_previous() as well as set(): fire once the change has succeeded, and nothing otherwise. - WP_Secrets_Provider_Conformance checks a write, an overridden action, a delete, a retired version, a network-scope change, and a refused write. It asks for every argument, so a provider that passes too few fails on the count. wp_secret_changed also gains a seventh argument, `bool $network`. A network secret and a site secret can share a name, and a listener could not tell which one had changed. A site-scope change belongs to the current site. This is the feature plugin's src/ and tests at 0.2.3. See #66187. --- .../class-wp-secrets-libsodium-provider.php | 11 +- .../secrets/interface-wp-secrets-provider.php | 18 +- .../class-wp-secrets-provider-conformance.php | 163 ++++++++++++++++++ tests/phpunit/tests/secrets/networkScope.php | 33 ++++ tests/phpunit/tests/secrets/versionSlots.php | 4 +- .../phpunit/tests/secrets/wpDeleteSecret.php | 2 +- .../tests/secrets/wpImportOptionAsSecret.php | 2 +- .../secrets/wpSetSecretAndWpGetSecret.php | 9 +- 8 files changed, 227 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php index 6c6abb7fad054..e3f40d2413cdf 100644 --- a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php @@ -387,6 +387,8 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ * @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', @@ -395,7 +397,8 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ get_current_user_id(), $new_slot['created'], $old_fingerprint, - $new_slot['fingerprint'] + $new_slot['fingerprint'], + (bool) $network ); return true; @@ -525,7 +528,8 @@ public function delete( $name, $network = false ) { get_current_user_id(), time(), $this->stored_fingerprint( $existing ), - '' + '', + (bool) $network ); } @@ -591,7 +595,8 @@ public function retire_previous( $name, $network = false ) { get_current_user_id(), time(), $retired_fingerprint, - '' + '', + (bool) $network ); return true; diff --git a/src/wp-includes/secrets/interface-wp-secrets-provider.php b/src/wp-includes/secrets/interface-wp-secrets-provider.php index 89518ed74d377..b91cb97fab74b 100644 --- a/src/wp-includes/secrets/interface-wp-secrets-provider.php +++ b/src/wp-includes/secrets/interface-wp-secrets-provider.php @@ -102,11 +102,13 @@ public function get( $name, $version, $network = false ); * 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. 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 + * 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. + * the situation it matters most, so WP_Secrets_Provider_Conformance checks it. + * A write that fails fires nothing. * * @since 7.2.0 * @@ -132,6 +134,10 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ * * 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. @@ -146,6 +152,10 @@ public function delete( $name, $network = false ); * 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. diff --git a/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php b/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php index 4df63af47ae16..9ce441a9b6808 100644 --- a/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php +++ b/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php @@ -58,6 +58,48 @@ private function require_writable( WP_Secrets_Provider $provider ): void { } } + /** + * Every `wp_secret_changed` call seen since record_changes(), as its arguments. + * + * @var array> + */ + 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 { @@ -205,6 +247,127 @@ public function test_a_deleted_secret_reads_as_absent(): void { $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 { diff --git a/tests/phpunit/tests/secrets/networkScope.php b/tests/phpunit/tests/secrets/networkScope.php index 3d53b363c6162..5a244ad6dd2d5 100644 --- a/tests/phpunit/tests/secrets/networkScope.php +++ b/tests/phpunit/tests/secrets/networkScope.php @@ -69,6 +69,39 @@ public function test_retire_clears_the_previous_slot(): void { $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' ); diff --git a/tests/phpunit/tests/secrets/versionSlots.php b/tests/phpunit/tests/secrets/versionSlots.php index 2714ceff6c775..e7fdcec739981 100644 --- a/tests/phpunit/tests/secrets/versionSlots.php +++ b/tests/phpunit/tests/secrets/versionSlots.php @@ -193,13 +193,13 @@ function ( ...$args ) use ( &$captured ) { $captured = $args; }, 10, - 6 + 7 ); wp_retire_secret_version( 'myplugin/api-key' ); $this->assertIsArray( $captured ); - $this->assertCount( 6, $captured ); + $this->assertCount( 7, $captured ); list( $name, $action, , , $old_fingerprint, $new_fingerprint ) = $captured; diff --git a/tests/phpunit/tests/secrets/wpDeleteSecret.php b/tests/phpunit/tests/secrets/wpDeleteSecret.php index 255ea281cbf07..485cf5b22a771 100644 --- a/tests/phpunit/tests/secrets/wpDeleteSecret.php +++ b/tests/phpunit/tests/secrets/wpDeleteSecret.php @@ -39,7 +39,7 @@ function ( ...$args ) use ( &$captured ) { $captured = $args; }, 10, - 6 + 7 ); wp_delete_secret( 'myplugin/api-key' ); diff --git a/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php b/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php index 5dba3817a6d9b..4b7e04d0878eb 100644 --- a/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php +++ b/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php @@ -83,7 +83,7 @@ function ( ...$args ) use ( &$captured ) { $captured = $args; }, 10, - 6 + 7 ); wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ); diff --git a/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php b/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php index ebd4820f20b8b..9c0a692fbbd72 100644 --- a/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php +++ b/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php @@ -150,18 +150,19 @@ function ( ...$args ) use ( &$captured ) { $captured = $args; }, 10, - 6 + 7 ); wp_set_secret( 'myplugin/api-key', 'value' ); $this->assertNotNull( $captured ); - list( $name, $action, $actor_id, $timestamp, $old_fingerprint, $new_fingerprint ) = $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 { @@ -178,7 +179,7 @@ function ( ...$args ) use ( &$captured ) { $captured = $args; }, 10, - 6 + 7 ); wp_set_secret( 'myplugin/api-key', 'second-value' ); @@ -218,7 +219,7 @@ function ( ...$args ) use ( &$captured_args ) { $captured_args = $args; }, 10, - 6 + 7 ); wp_set_secret( 'myplugin/api-key', 'a-plaintext-value-that-must-not-leak' ); From 5aa71c2f4be552e59af3bea404a0cede073c6717 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Wed, 7 Oct 2026 14:03:20 -0700 Subject: [PATCH 10/12] Secrets API: declare native types, and narrow static types where they hold Parameters and return values carry PHP 7.4 type declarations: the public functions, the three interfaces and their implementations, and the internal classes. A return that needs a union type, such as WP_Secret|null|WP_Error, stays in the docblock. The runtime checks the declarations replace are removed, with the tests that exercised them. A plaintext value stays untyped and checked: as `string`, PHP would coerce false to an empty secret and report success. The two Site Health filter callbacks stay untyped, since they receive whatever an earlier callback returned. Static types are narrowed where that holds: versions and slots, scopes, the protection boundary, Site Health statuses, and counts. A secret's name stays `string`, because the functions exist to validate one. This is the feature plugin's src/ and tests at 0.3.0. See #66187. --- src/wp-admin/includes/secrets-site-health.php | 23 +++--- src/wp-includes/secrets.php | 77 ++++++++----------- src/wp-includes/secrets/class-wp-secret.php | 37 ++++----- .../class-wp-secrets-broken-keyring.php | 8 +- .../class-wp-secrets-broken-provider.php | 22 +++--- .../secrets/class-wp-secrets-broken-store.php | 10 +-- .../secrets/class-wp-secrets-cipher.php | 31 +++++--- .../class-wp-secrets-config-key-provider.php | 22 +++--- .../secrets/class-wp-secrets-key-manager.php | 8 +- .../class-wp-secrets-libsodium-provider.php | 35 +++++---- .../secrets/class-wp-secrets-option-store.php | 17 ++-- .../secrets/interface-wp-secrets-keyring.php | 6 +- .../secrets/interface-wp-secrets-provider.php | 20 +++-- .../secrets/interface-wp-secrets-store.php | 8 +- .../includes/secrets/class-mock-keyring.php | 24 ++++-- .../includes/secrets/class-mock-store.php | 14 ++-- .../secrets/trait-wp-secrets-assertions.php | 2 + tests/phpunit/tests/secrets/networkScope.php | 2 +- tests/phpunit/tests/secrets/provider.php | 16 ++-- .../secrets/readonlyProviderConformance.php | 16 ++-- tests/phpunit/tests/secrets/wpListSecrets.php | 9 --- tests/phpunit/tests/secrets/wpSecret.php | 6 -- .../phpunit/tests/secrets/wpSecretsCipher.php | 20 ++--- .../secrets/wpSecretsConfigKeyProvider.php | 13 +--- .../tests/secrets/wpSecretsKeyManager.php | 15 ++-- .../tests/secrets/wpSecretsOptionStore.php | 9 --- .../tests/secrets/wpSecretsValidateName.php | 10 +-- .../secrets/wpSetSecretAndWpGetSecret.php | 2 +- 28 files changed, 232 insertions(+), 250 deletions(-) diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index fab1be4bad7e0..018ac2085ea10 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -51,9 +51,10 @@ function wp_secrets_site_health_tests( $tests ) { * @param string $description HTML description, normally one or more

    elements. * @return array * - * @phpstan-return array{test: string, label: string, status: string, badge: array{label: string, color: string}, description: string} + * @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( $test, $label, $status, $description ) { +function wp_secrets_site_health_result( string $test, string $label, string $status, string $description ): array { $colors = array( 'good' => 'green', 'recommended' => 'orange', @@ -79,9 +80,9 @@ function wp_secrets_site_health_result( $test, $label, $status, $description ) { * * @return array * - * @phpstan-return array{test: string, label: string, status: string, badge: array{label: string, color: string}, description: string} + * @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() { +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' ) ) { @@ -138,9 +139,9 @@ function wp_secrets_site_health_test_key_source() { * * @return array * - * @phpstan-return array{test: string, label: string, status: string, badge: array{label: string, color: string}, description: string} + * @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() { +function wp_secrets_site_health_test_undecryptable(): array { $broken = wp_secrets_site_health_find_undecryptable( false ); if ( is_multisite() && is_super_admin() ) { @@ -193,7 +194,7 @@ function wp_secrets_site_health_test_undecryptable() { * * @phpstan-return list */ -function wp_secrets_site_health_find_undecryptable( $network ) { +function wp_secrets_site_health_find_undecryptable( bool $network ): array { $entries = $network ? wp_list_network_secrets() : wp_list_secrets(); if ( is_wp_error( $entries ) ) { @@ -223,9 +224,9 @@ function wp_secrets_site_health_find_undecryptable( $network ) { * * @return array * - * @phpstan-return array{test: string, label: string, status: string, badge: array{label: string, color: string}, description: string} + * @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() { +function wp_secrets_site_health_test_needs_rotation(): array { $count = wp_secrets_site_health_count_needing_rotation( false ); if ( is_multisite() && is_super_admin() ) { @@ -269,8 +270,10 @@ function wp_secrets_site_health_test_needs_rotation() { * * @param bool $network Whether to check network-scope secrets. * @return int + * + * @phpstan-return int<0, max> */ -function wp_secrets_site_health_count_needing_rotation( $network ) { +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 ) ) { diff --git a/src/wp-includes/secrets.php b/src/wp-includes/secrets.php index 18a809c27e557..b8a7176fcabba 100644 --- a/src/wp-includes/secrets.php +++ b/src/wp-includes/secrets.php @@ -50,8 +50,7 @@ /** * A caller passed an argument this API cannot act on. * - * An unrecognized version constant, an invalid scope or slot, or a non-string - * namespace. + * 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 @@ -222,11 +221,11 @@ function wp_secrets_memzero( &$value ): void { * @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( $name ) { - if ( ! is_string( $name ) || '' === $name ) { +function wp_secrets_validate_name( string $name ) { + if ( '' === $name ) { return new WP_Error( WP_SECRETS_ERROR_INVALID_NAME, - __( 'Secret names must be non-empty strings.' ) + __( 'Secret names must not be empty.' ) ); } @@ -317,7 +316,7 @@ function wp_secrets_validate_name( $name ) { * * @return WP_Secrets_Store */ -function _wp_secrets_get_store() { +function _wp_secrets_get_store(): WP_Secrets_Store { static $store = null; if ( $store instanceof WP_Secrets_Store ) { @@ -353,7 +352,7 @@ function _wp_secrets_get_store() { * * @return WP_Secrets_Key_Manager */ -function _wp_secrets_get_key_manager() { +function _wp_secrets_get_key_manager(): WP_Secrets_Key_Manager { static $key_manager = null; if ( $key_manager instanceof WP_Secrets_Key_Manager ) { @@ -388,7 +387,7 @@ function _wp_secrets_get_key_manager() { * * @return bool */ -function wp_using_secrets_dropin() { +function wp_using_secrets_dropin(): bool { return ! empty( $GLOBALS['wp_secrets_dropin_loaded'] ); } @@ -424,7 +423,7 @@ function wp_using_secrets_dropin() { * @param string $value The plaintext value to store. * @return true|WP_Error */ -function wp_set_secret( $name, $value ) { +function wp_set_secret( string $name, $value ) { return _wp_secrets_set( $name, $value, false ); } @@ -448,11 +447,11 @@ function wp_set_secret( $name, $value ) { * @param string $name The secret's namespaced name to store it under. * @return true|WP_Error */ -function wp_import_option_as_secret( $option, $name ) { - if ( ! is_string( $option ) || '' === $option ) { +function wp_import_option_as_secret( string $option, string $name ) { + if ( '' === $option ) { return new WP_Error( WP_SECRETS_ERROR_INVALID_VALUE, - __( 'Option name must be a non-empty string.' ) + __( 'The option name must not be empty.' ) ); } @@ -489,8 +488,10 @@ function wp_import_option_as_secret( $option, $name ) { * @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( $name, $version = WP_Secret_Version::CURRENT ) { +function wp_get_secret( string $name, string $version = WP_Secret_Version::CURRENT ) { return _wp_secrets_get( $name, $version, false ); } @@ -502,7 +503,7 @@ function wp_get_secret( $name, $version = WP_Secret_Version::CURRENT ) { * @param string $name The secret's namespaced name. * @return true|WP_Error */ -function wp_delete_secret( $name ) { +function wp_delete_secret( string $name ) { return _wp_secrets_delete( $name, false ); } @@ -520,7 +521,7 @@ function wp_delete_secret( $name ) { * @param string $name The secret's namespaced name. * @return true|WP_Error */ -function wp_retire_secret_version( $name ) { +function wp_retire_secret_version( string $name ) { return _wp_secrets_retire( $name, false ); } @@ -541,7 +542,7 @@ function wp_retire_secret_version( $name ) { * * @phpstan-return list|WP_Error */ -function wp_list_secrets( $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 parameter name the proposal specifies. return _wp_secrets_list( $namespace, false ); } @@ -559,7 +560,7 @@ function wp_list_secrets( $namespace = '' ) { // phpcs:ignore Universal.NamingCo * @param string $value The plaintext value to store. * @return true|WP_Error */ -function wp_set_network_secret( $name, $value ) { +function wp_set_network_secret( string $name, $value ) { return _wp_secrets_set( $name, $value, true ); } @@ -571,8 +572,10 @@ function wp_set_network_secret( $name, $value ) { * @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( $name, $version = WP_Secret_Version::CURRENT ) { +function wp_get_network_secret( string $name, string $version = WP_Secret_Version::CURRENT ) { return _wp_secrets_get( $name, $version, true ); } @@ -584,7 +587,7 @@ function wp_get_network_secret( $name, $version = WP_Secret_Version::CURRENT ) { * @param string $name The secret's namespaced name. * @return true|WP_Error */ -function wp_delete_network_secret( $name ) { +function wp_delete_network_secret( string $name ) { return _wp_secrets_delete( $name, true ); } @@ -596,7 +599,7 @@ function wp_delete_network_secret( $name ) { * @param string $name The secret's namespaced name. * @return true|WP_Error */ -function wp_retire_network_secret_version( $name ) { +function wp_retire_network_secret_version( string $name ) { return _wp_secrets_retire( $name, true ); } @@ -611,7 +614,7 @@ function wp_retire_network_secret_version( $name ) { * * @phpstan-return list|WP_Error */ -function wp_list_network_secrets( $namespace = '' ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.namespaceFound -- matches wp_list_secrets()'s own signature. +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 ); } @@ -640,7 +643,7 @@ function wp_list_network_secrets( $namespace = '' ) { // phpcs:ignore Universal. * * @return WP_Secrets_Provider */ -function _wp_secrets_get_provider() { +function _wp_secrets_get_provider(): WP_Secrets_Provider { static $provider = null; if ( $provider instanceof WP_Secrets_Provider ) { @@ -691,7 +694,7 @@ function _wp_secrets_get_provider() { * * @return bool */ -function wp_secrets_provider_is_writable() { +function wp_secrets_provider_is_writable(): bool { return _wp_secrets_get_provider()->is_writable(); } @@ -704,7 +707,7 @@ function wp_secrets_provider_is_writable() { * * @return string */ -function wp_secrets_provider_label() { +function wp_secrets_provider_label(): string { return _wp_secrets_get_provider()->get_label(); } @@ -721,7 +724,7 @@ function wp_secrets_provider_label() { * wp_secret_changed. * @return true|WP_Error */ -function _wp_secrets_set( $name, $value, $network, $needs_rotation = false, $action_override = null ) { +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 ); } @@ -734,8 +737,10 @@ function _wp_secrets_set( $name, $value, $network, $needs_rotation = false, $act * @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( $name, $version, $network ) { +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 @@ -767,7 +772,7 @@ function _wp_secrets_get( $name, $version, $network ) { * @param bool $network Whether this is a network-scope secret. * @return true|WP_Error */ -function _wp_secrets_delete( $name, $network ) { +function _wp_secrets_delete( string $name, bool $network ) { return _wp_secrets_get_provider()->delete( $name, $network ); } @@ -780,7 +785,7 @@ function _wp_secrets_delete( $name, $network ) { * @param bool $network Whether this is a network-scope secret. * @return true|WP_Error */ -function _wp_secrets_retire( $name, $network ) { +function _wp_secrets_retire( string $name, bool $network ) { return _wp_secrets_get_provider()->retire_previous( $name, $network ); } @@ -795,20 +800,6 @@ function _wp_secrets_retire( $name, $network ) { * * @phpstan-return list|WP_Error */ -function _wp_secrets_list( $name_prefix, $network ) { - // See _wp_secrets_get() for why this is checked here and not per-provider. - if ( ! is_string( $name_prefix ) ) { - _doing_it_wrong( - __FUNCTION__, - __( 'The namespace must be a string.' ), - '7.2.0' - ); - - return new WP_Error( - WP_SECRETS_ERROR_INVALID_ARGUMENT, - __( 'The namespace must be a string.' ) - ); - } - +function _wp_secrets_list( string $name_prefix, bool $network ) { return _wp_secrets_get_provider()->list_secrets( $name_prefix, $network ); } diff --git a/src/wp-includes/secrets/class-wp-secret.php b/src/wp-includes/secrets/class-wp-secret.php index 1aef83ba609d5..2e725d6cfb05b 100644 --- a/src/wp-includes/secrets/class-wp-secret.php +++ b/src/wp-includes/secrets/class-wp-secret.php @@ -94,20 +94,20 @@ final class WP_Secret implements JsonSerializable { * @param string $value The decrypted plaintext. * @param string $fingerprint Keyed fingerprint of $value. * - * @throws InvalidArgumentException If any argument is not a non-empty string - * (value may be empty, but must be a string). + * @throws InvalidArgumentException If $name or $fingerprint is empty, or if $value + * is not a string. $value may be empty. */ - public function __construct( $name, $value, $fingerprint ) { - if ( ! is_string( $name ) || '' === $name ) { - throw new InvalidArgumentException( 'WP_Secret requires a non-empty string name.' ); + 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 ( ! is_string( $fingerprint ) || '' === $fingerprint ) { - throw new InvalidArgumentException( 'WP_Secret requires a non-empty string fingerprint.' ); + if ( '' === $fingerprint ) { + throw new InvalidArgumentException( 'WP_Secret requires a non-empty fingerprint.' ); } $this->name = $name; @@ -158,16 +158,12 @@ public function reveal() { * @param WP_Error $reason Why the value cannot be produced. Returned * verbatim by reveal(). * - * @throws InvalidArgumentException If $reason is not a WP_Error, or if $name or - * $fingerprint fail the constructor's checks. + * @throws InvalidArgumentException If $name or $fingerprint fail the constructor's + * checks. * * @return WP_Secret */ - public static function withheld( $name, $fingerprint, $reason ) { - if ( ! $reason instanceof WP_Error ) { - throw new InvalidArgumentException( 'A withheld WP_Secret requires a WP_Error reason.' ); - } - + public static function withheld( string $name, string $fingerprint, WP_Error $reason ): WP_Secret { $secret = new self( $name, '', $fingerprint ); $secret->withheld_reason = $reason; @@ -184,7 +180,7 @@ public static function withheld( $name, $fingerprint, $reason ) { * * @return string */ - public function fingerprint() { + public function fingerprint(): string { return $this->fingerprint; } @@ -195,7 +191,7 @@ public function fingerprint() { * * @return string */ - public function get_name() { + public function get_name(): string { return $this->name; } @@ -208,7 +204,7 @@ public function get_name() { * * @phpstan-return non-falsy-string */ - private function mask() { + private function mask(): string { return '[secret:' . $this->name . ']'; } @@ -223,7 +219,7 @@ private function mask() { * * @phpstan-return non-falsy-string */ - public function __toString() { + public function __toString(): string { return $this->mask(); } @@ -253,8 +249,7 @@ public function __debugInfo(): array { * * @phpstan-return non-falsy-string */ - #[\ReturnTypeWillChange] - public function jsonSerialize() { + public function jsonSerialize(): string { return $this->mask(); } @@ -311,7 +306,7 @@ public function __serialize() { * * @return void */ - public function __unserialize( $data ) { + public function __unserialize( array $data ): void { throw new LogicException( 'WP_Secret cannot be unserialized.' ); } diff --git a/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php b/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php index b0da8b44e3ab0..85b51f9e05e67 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php @@ -30,7 +30,7 @@ final class WP_Secrets_Broken_Keyring implements WP_Secrets_Keyring { * @param string $key_material Ignored. * @return WP_Error */ - public function wrap( $key_material ) { + public function wrap( string $key_material ): WP_Error { return $this->error(); } @@ -42,7 +42,7 @@ public function wrap( $key_material ) { * @param string $wrapped Ignored. * @return WP_Error */ - public function unwrap( $wrapped ) { + public function unwrap( string $wrapped ): WP_Error { return $this->error(); } @@ -53,7 +53,7 @@ public function unwrap( $wrapped ) { * * @return string */ - public function get_key_source() { + public function get_key_source(): string { return __( 'broken secrets.php drop-in' ); } @@ -64,7 +64,7 @@ public function get_key_source() { * * @return WP_Error */ - private function 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.' ) diff --git a/src/wp-includes/secrets/class-wp-secrets-broken-provider.php b/src/wp-includes/secrets/class-wp-secrets-broken-provider.php index 5462c1aac49b6..f2eed41eaee27 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-provider.php @@ -31,7 +31,7 @@ final class WP_Secrets_Broken_Provider implements WP_Secrets_Provider { * * @return WP_Error */ - private function 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.' ) @@ -47,8 +47,10 @@ private function error() { * @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( $name, $version, $network = false ) { + public function get( string $name, string $version, bool $network = false ): WP_Error { return $this->error(); } @@ -64,7 +66,7 @@ public function get( $name, $version, $network = false ) { * @param string|null $action Ignored. * @return WP_Error */ - public function set( $name, $value, $network = false, $needs_rotation = false, $action = null ) { + public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ): WP_Error { return $this->error(); } @@ -77,7 +79,7 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ * @param bool $network Whether this is a network-scope secret. * @return WP_Error */ - public function delete( $name, $network = false ) { + public function delete( string $name, bool $network = false ): WP_Error { return $this->error(); } @@ -90,7 +92,7 @@ public function delete( $name, $network = false ) { * @param bool $network Whether this is a network-scope secret. * @return WP_Error */ - public function retire_previous( $name, $network = false ) { + public function retire_previous( string $name, bool $network = false ): WP_Error { return $this->error(); } @@ -103,7 +105,7 @@ public function retire_previous( $name, $network = false ) { * @param bool $network Whether to list network-scope secrets. * @return WP_Error */ - public function list_secrets( $name_prefix = '', $network = false ) { + public function list_secrets( string $name_prefix = '', bool $network = false ): WP_Error { return $this->error(); } @@ -114,7 +116,7 @@ public function list_secrets( $name_prefix = '', $network = false ) { * * @return string */ - public function get_label() { + public function get_label(): string { return __( 'Unavailable: the secrets.php drop-in did not load correctly' ); } @@ -127,8 +129,10 @@ public function get_label() { * @since 7.2.0 * * @return string + * + * @phpstan-return self::BOUNDARY_* */ - public function get_protection_boundary() { + public function get_protection_boundary(): string { return self::BOUNDARY_PROVIDER; } @@ -139,7 +143,7 @@ public function get_protection_boundary() { * * @return bool */ - public function is_writable() { + public function is_writable(): bool { return false; } } diff --git a/src/wp-includes/secrets/class-wp-secrets-broken-store.php b/src/wp-includes/secrets/class-wp-secrets-broken-store.php index 135f5dfa94591..82b1228a18482 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-store.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-store.php @@ -32,7 +32,7 @@ final class WP_Secrets_Broken_Store implements WP_Secrets_Store { * @param bool $network Ignored. * @return WP_Error */ - public function get( $name, $network = false ) { + public function get( string $name, bool $network = false ): WP_Error { return $this->error(); } @@ -46,7 +46,7 @@ public function get( $name, $network = false ) { * @param bool $network Ignored. * @return WP_Error */ - public function set( $name, $record, $network = false ) { + public function set( string $name, array $record, bool $network = false ): WP_Error { return $this->error(); } @@ -59,7 +59,7 @@ public function set( $name, $record, $network = false ) { * @param bool $network Ignored. * @return WP_Error */ - public function delete( $name, $network = false ) { + public function delete( string $name, bool $network = false ): WP_Error { return $this->error(); } @@ -71,7 +71,7 @@ public function delete( $name, $network = false ) { * @param bool $network Ignored. * @return WP_Error */ - public function list_names( $network = false ) { + public function list_names( bool $network = false ): WP_Error { return $this->error(); } @@ -83,7 +83,7 @@ public function list_names( $network = false ) { * * @return WP_Error */ - private function 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.' ) diff --git a/src/wp-includes/secrets/class-wp-secrets-cipher.php b/src/wp-includes/secrets/class-wp-secrets-cipher.php index 6ed90c7b155db..0076bfacbf09a 100644 --- a/src/wp-includes/secrets/class-wp-secrets-cipher.php +++ b/src/wp-includes/secrets/class-wp-secrets-cipher.php @@ -66,8 +66,10 @@ final class WP_Secrets_Cipher { * '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( $master_key, $scope, $site_id, $name, $slot, $plaintext ) { + 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 ) ) { @@ -136,8 +138,11 @@ public function encrypt_value( $master_key, $scope, $site_id, $name, $slot, $pla * @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( $master_key, $scope, $site_id, $name, $slot, $record ) { + 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 ) ) { @@ -206,7 +211,7 @@ public function decrypt_value( $master_key, $scope, $site_id, $name, $slot, $rec * @param string $plaintext Value to fingerprint. * @return string|WP_Error 32-character hex string on success. WP_Error on failure. */ - public function fingerprint( $master_key, $plaintext ) { + public function fingerprint( string $master_key, $plaintext ) { if ( ! $this->is_valid_master_key( $master_key ) ) { return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, @@ -255,8 +260,11 @@ public function fingerprint( $master_key, $plaintext ) { * @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( $purpose, $scope, $site_id, $name, $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 ); } @@ -280,13 +288,16 @@ private function build_aad( $purpose, $scope, $site_id, $name, $slot ) { * @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( $master_key, $scope, $site_id, $name, $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 ( ! is_int( $site_id ) || $site_id < 0 ) { + if ( $site_id < 0 ) { return $this->invalid_argument( __( 'The site id must be a non-negative integer.' ) ); } @@ -321,7 +332,7 @@ private function validate_common( $master_key, $scope, $site_id, $name, $slot ) * @param string $message What the caller got wrong. * @return WP_Error */ - private function invalid_argument( $message ) { + 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 ); @@ -332,11 +343,11 @@ private function invalid_argument( $message ) { * * @since 7.2.0 * - * @param mixed $master_key Candidate master key. + * @param string $master_key Candidate master key. * @return bool */ - private function is_valid_master_key( $master_key ) { - return is_string( $master_key ) && SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES === strlen( $master_key ); + private function is_valid_master_key( string $master_key ): bool { + return SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES === strlen( $master_key ); } /** diff --git a/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php b/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php index a7ca14f7f62df..a56e4582c1fd1 100644 --- a/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php @@ -72,8 +72,8 @@ final class WP_Secrets_Config_Key_Provider implements WP_Secrets_Keyring { * WP_SECRETS_KEY. Used only during a site-key * rotation, to unwrap under the outgoing key. */ - public function __construct( $use_previous_key = false ) { - $this->use_previous_key = (bool) $use_previous_key; + public function __construct( bool $use_previous_key = false ) { + $this->use_previous_key = $use_previous_key; } /** @@ -84,11 +84,11 @@ public function __construct( $use_previous_key = false ) { * @param string $key_material Raw key material to protect. * @return string|WP_Error */ - public function wrap( $key_material ) { - if ( ! is_string( $key_material ) || '' === $key_material ) { + public function wrap( string $key_material ) { + if ( '' === $key_material ) { return new WP_Error( WP_SECRETS_ERROR_INVALID_VALUE, - __( 'Key material to wrap must be a non-empty string.' ) + __( 'Key material to wrap must not be empty.' ) ); } @@ -121,8 +121,8 @@ public function wrap( $key_material ) { * @param string $wrapped An opaque value previously returned by wrap(). * @return string|WP_Error */ - public function unwrap( $wrapped ) { - if ( ! is_string( $wrapped ) || '' === $wrapped ) { + public function unwrap( string $wrapped ) { + if ( '' === $wrapped ) { return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, __( 'Nothing to unwrap.' ) @@ -176,7 +176,7 @@ public function unwrap( $wrapped ) { * * @return string */ - public function get_key_source() { + 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)' @@ -260,7 +260,7 @@ private function get_site_key() { * @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 ) { + 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; @@ -283,7 +283,7 @@ private function are_usable_salt_values( $logged_in_key, $logged_in_salt ) { * @param mixed $value Candidate value. * @return bool */ - private function is_canonical_base64_32( $value ) { + private function is_canonical_base64_32( $value ): bool { return null !== $this->decode_canonical_base64_32( $value ); } @@ -295,7 +295,7 @@ private function is_canonical_base64_32( $value ) { * @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 ) { + private function decode_canonical_base64_32( $value ): ?string { if ( ! is_string( $value ) ) { return null; } diff --git a/src/wp-includes/secrets/class-wp-secrets-key-manager.php b/src/wp-includes/secrets/class-wp-secrets-key-manager.php index 8f66ad37fff74..e066042f094f5 100644 --- a/src/wp-includes/secrets/class-wp-secrets-key-manager.php +++ b/src/wp-includes/secrets/class-wp-secrets-key-manager.php @@ -126,7 +126,7 @@ public function __construct( ?WP_Secrets_Keyring $keyring = null ) { * * @return WP_Secrets_Keyring */ - public function get_keyring() { + public function get_keyring(): WP_Secrets_Keyring { return $this->keyring; } @@ -142,8 +142,10 @@ public function get_keyring() { * 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( $scope, $site_id = null ) { + 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".' ); @@ -158,7 +160,7 @@ public function get_master_key( $scope, $site_id = null ) { } else { $subkey_id = null === $site_id ? get_current_blog_id() : $site_id; - if ( ! is_int( $subkey_id ) || $subkey_id < 1 ) { + if ( $subkey_id < 1 ) { $message = __( 'The site id must be a positive integer.' ); _doing_it_wrong( __METHOD__, $message, '7.2.0' ); diff --git a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php index e3f40d2413cdf..5747dcb6e9eb1 100644 --- a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php @@ -74,7 +74,7 @@ public function __construct( WP_Secrets_Store $store, WP_Secrets_Key_Manager $ke * * @return string */ - public function get_label() { + public function get_label(): string { $keyring = $this->key_manager->get_keyring(); return sprintf( @@ -90,8 +90,10 @@ public function get_label() { * @since 7.2.0 * * @return string + * + * @phpstan-return self::BOUNDARY_* */ - public function get_protection_boundary() { + public function get_protection_boundary(): string { return self::BOUNDARY_WORDPRESS; } @@ -106,7 +108,7 @@ public function get_protection_boundary() { * * @return bool */ - public function is_writable() { + public function is_writable(): bool { return true; } /** @@ -170,7 +172,7 @@ private function validate_record_shape( $record ) { * or WP_Error only if the store itself is * unavailable. */ - private function read_prior_record( $store, $name, $network ) { + private function read_prior_record( WP_Secrets_Store $store, string $name, bool $network ) { $existing = $store->get( $name, $network ); if ( is_wp_error( $existing ) ) { @@ -192,7 +194,7 @@ private function read_prior_record( $store, $name, $network ) { * @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 ) { + private function stored_fingerprint( $record ): string { if ( ! is_array( $record ) || ! isset( $record['current'] ) || ! is_array( $record['current'] ) ) { return ''; } @@ -208,7 +210,7 @@ private function stored_fingerprint( $record ) { * @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 ) { + private function slot_fingerprint( $slot ): string { if ( ! is_array( $slot ) || ! isset( $slot['fingerprint'] ) || ! is_string( $slot['fingerprint'] ) ) { return ''; } @@ -241,8 +243,9 @@ private function slot_fingerprint( $slot ) { * 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( $cipher, $master_key, $scope, $site_id, $name, $current_slot ) { + 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 ) ) { @@ -286,7 +289,7 @@ private function demote_slot( $cipher, $master_key, $scope, $site_id, $name, $cu * wp_import_option_as_secret() passes 'imported'. * @return true|WP_Error */ - public function set( $name, $value, $network = false, $needs_rotation = false, $action = null ) { + 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 ) ) { @@ -398,7 +401,7 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ $new_slot['created'], $old_fingerprint, $new_slot['fingerprint'], - (bool) $network + $network ); return true; @@ -413,8 +416,10 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ * @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( $name, $version, $network = false ) { + public function get( string $name, string $version, bool $network = false ) { $name_check = wp_secrets_validate_name( $name ); @@ -498,7 +503,7 @@ public function get( $name, $version, $network = false ) { * @param bool $network Whether this is a network-scope secret. * @return true|WP_Error */ - public function delete( $name, $network = false ) { + public function delete( string $name, bool $network = false ) { $name_check = wp_secrets_validate_name( $name ); if ( is_wp_error( $name_check ) ) { @@ -529,7 +534,7 @@ public function delete( $name, $network = false ) { time(), $this->stored_fingerprint( $existing ), '', - (bool) $network + $network ); } @@ -551,7 +556,7 @@ public function delete( $name, $network = false ) { * @param bool $network Whether this is a network-scope secret. * @return true|WP_Error */ - public function retire_previous( $name, $network = false ) { + public function retire_previous( string $name, bool $network = false ) { $name_check = wp_secrets_validate_name( $name ); if ( is_wp_error( $name_check ) ) { @@ -596,7 +601,7 @@ public function retire_previous( $name, $network = false ) { time(), $retired_fingerprint, '', - (bool) $network + $network ); return true; @@ -632,7 +637,7 @@ public function retire_previous( $name, $network = false ) { * * @phpstan-return list|WP_Error */ - public function list_secrets( $name_prefix = '', $network = false ) { + public function list_secrets( string $name_prefix = '', bool $network = false ) { $store = $this->store; diff --git a/src/wp-includes/secrets/class-wp-secrets-option-store.php b/src/wp-includes/secrets/class-wp-secrets-option-store.php index a4f7ef91ac910..3f849948a7bb1 100644 --- a/src/wp-includes/secrets/class-wp-secrets-option-store.php +++ b/src/wp-includes/secrets/class-wp-secrets-option-store.php @@ -48,7 +48,7 @@ final class WP_Secrets_Option_Store implements WP_Secrets_Store { * @param bool $network Whether this is a network-scope secret. * @return array|null|WP_Error */ - public function get( $name, $network = false ) { + 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 ); @@ -76,14 +76,7 @@ public function get( $name, $network = false ) { * @param bool $network Whether this is a network-scope secret. * @return true|WP_Error */ - public function set( $name, $record, $network = false ) { - if ( ! is_array( $record ) ) { - return new WP_Error( - WP_SECRETS_ERROR_RECORD_MALFORMED, - __( 'A secret record must be an array.' ) - ); - } - + public function set( string $name, array $record, bool $network = false ) { $option_name = $this->option_name( $name, $network ); if ( $network ) { @@ -123,7 +116,7 @@ public function set( $name, $record, $network = false ) { * @param bool $network Whether this is a network-scope secret. * @return true|WP_Error */ - public function delete( $name, $network = false ) { + 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 ); @@ -155,7 +148,7 @@ public function delete( $name, $network = false ) { * @param bool $network Whether to list network-scope secrets. * @return string[]|WP_Error */ - public function list_names( $network = false ) { + public function list_names( bool $network = false ) { global $wpdb; $prefix = $network ? self::NETWORK_PREFIX : self::SITE_PREFIX; @@ -208,7 +201,7 @@ public function list_names( $network = false ) { * @param bool $network Whether this is a network-scope secret. * @return string */ - private function option_name( $name, $network ) { + private function option_name( string $name, bool $network ): string { return ( $network ? self::NETWORK_PREFIX : self::SITE_PREFIX ) . $name; } } diff --git a/src/wp-includes/secrets/interface-wp-secrets-keyring.php b/src/wp-includes/secrets/interface-wp-secrets-keyring.php index ba702b7922fc8..d378062908a9e 100644 --- a/src/wp-includes/secrets/interface-wp-secrets-keyring.php +++ b/src/wp-includes/secrets/interface-wp-secrets-keyring.php @@ -35,7 +35,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( $key_material ); + public function wrap( string $key_material ); /** * Unwraps (decrypts) previously wrapped key material. @@ -45,7 +45,7 @@ public function wrap( $key_material ); * @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( $wrapped ); + public function unwrap( string $wrapped ); /** * A human-readable description of the key source, for Site Health. @@ -54,5 +54,5 @@ public function unwrap( $wrapped ); * * @return string */ - public function get_key_source(); + public function get_key_source(): string; } diff --git a/src/wp-includes/secrets/interface-wp-secrets-provider.php b/src/wp-includes/secrets/interface-wp-secrets-provider.php index b91cb97fab74b..bd11a88c71c11 100644 --- a/src/wp-includes/secrets/interface-wp-secrets-provider.php +++ b/src/wp-includes/secrets/interface-wp-secrets-provider.php @@ -91,8 +91,10 @@ interface WP_Secrets_Provider { * 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( $name, $version, $network = false ); + public function get( string $name, string $version, bool $network = false ); /** * Stores a secret. @@ -127,7 +129,7 @@ public function get( $name, $version, $network = false ); * 'updated'. * @return true|WP_Error */ - public function set( $name, $value, $network = false, $needs_rotation = false, $action = null ); + public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ); /** * Deletes a secret. @@ -144,7 +146,7 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ * @param bool $network Whether this is a network-scope secret. * @return true|WP_Error */ - public function delete( $name, $network = false ); + public function delete( string $name, bool $network = false ); /** * Clears a secret's previous version, if this provider keeps one. @@ -162,7 +164,7 @@ public function delete( $name, $network = false ); * @param bool $network Whether this is a network-scope secret. * @return true|WP_Error */ - public function retire_previous( $name, $network = false ); + public function retire_previous( string $name, bool $network = false ); /** * Lists secrets by name and metadata. Never values. @@ -177,7 +179,7 @@ public function retire_previous( $name, $network = false ); * * @phpstan-return list|WP_Error */ - public function list_secrets( $name_prefix = '', $network = false ); + public function list_secrets( string $name_prefix = '', bool $network = false ); /** * A short human-readable name for this provider, for Site Health. @@ -190,7 +192,7 @@ public function list_secrets( $name_prefix = '', $network = false ); * * @return string */ - public function get_label(); + public function get_label(): string; /** * Where secrets this provider holds are actually protected. @@ -204,8 +206,10 @@ public function get_label(); * @since 7.2.0 * * @return string + * + * @phpstan-return self::BOUNDARY_* */ - public function get_protection_boundary(); + public function get_protection_boundary(): string; /** * Whether wp_set_secret() can succeed against this provider. @@ -219,5 +223,5 @@ public function get_protection_boundary(); * * @return bool */ - public function is_writable(); + public function is_writable(): bool; } diff --git a/src/wp-includes/secrets/interface-wp-secrets-store.php b/src/wp-includes/secrets/interface-wp-secrets-store.php index f2841479191a1..81759a483bec4 100644 --- a/src/wp-includes/secrets/interface-wp-secrets-store.php +++ b/src/wp-includes/secrets/interface-wp-secrets-store.php @@ -34,7 +34,7 @@ interface WP_Secrets_Store { * @return array|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( $name, $network = false ); + public function get( string $name, bool $network = false ); /** * Writes a secret's record. @@ -47,7 +47,7 @@ public function get( $name, $network = false ); * @return bool|WP_Error True on success. WP_Error on failure, including when the * store does not accept writes. */ - public function set( $name, $record, $network = false ); + public function set( string $name, array $record, bool $network = false ); /** * Deletes a secret's record. @@ -59,7 +59,7 @@ public function set( $name, $record, $network = false ); * @return bool|WP_Error True on success (including if it did not exist). * WP_Error on failure. */ - public function delete( $name, $network = false ); + 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 +69,5 @@ public function delete( $name, $network = false ); * @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( $network = false ); + public function list_names( bool $network = false ); } diff --git a/tests/phpunit/includes/secrets/class-mock-keyring.php b/tests/phpunit/includes/secrets/class-mock-keyring.php index 999416082a24f..e6e72732443dc 100644 --- a/tests/phpunit/includes/secrets/class-mock-keyring.php +++ b/tests/phpunit/includes/secrets/class-mock-keyring.php @@ -27,6 +27,8 @@ class Mock_Keyring implements WP_Secrets_Keyring { * Number of times wrap() has been called. * * @var int + * + * @phpstan-var int<0, max> */ private $wrap_calls = 0; @@ -34,10 +36,12 @@ class Mock_Keyring implements WP_Secrets_Keyring { * Number of times unwrap() has been called. * * @var int + * + * @phpstan-var int<0, max> */ private $unwrap_calls = 0; - public function wrap( $key_material ) { + public function wrap( string $key_material ) { ++$this->wrap_calls; if ( $this->fail_wrap ) { @@ -50,14 +54,14 @@ public function wrap( $key_material ) { return self::MARKER . base64_encode( $nonce . $key_material . $tag ); } - public function unwrap( $wrapped ) { + 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 ( ! is_string( $wrapped ) || 0 !== strpos( $wrapped, self::MARKER ) ) { + if ( 0 !== strpos( $wrapped, self::MARKER ) ) { return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, 'Mock_Keyring: not a value this keyring wrapped.' ); } @@ -78,7 +82,7 @@ public function unwrap( $wrapped ) { return $key_material; } - public function get_key_source() { + public function get_key_source(): string { return 'mock keyring'; } @@ -87,7 +91,7 @@ public function get_key_source() { * * @return $this */ - public function configure_fail_wrap( $fail = true ) { + public function configure_fail_wrap( bool $fail = true ): self { $this->fail_wrap = $fail; return $this; @@ -98,7 +102,7 @@ public function configure_fail_wrap( $fail = true ) { * * @return $this */ - public function configure_fail_unwrap( $fail = true ) { + public function configure_fail_unwrap( bool $fail = true ): self { $this->fail_unwrap = $fail; return $this; @@ -106,15 +110,19 @@ public function configure_fail_unwrap( $fail = true ) { /** * @return int Number of times wrap() has been called. + * + * @phpstan-return int<0, max> */ - public function wrap_call_count() { + 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() { + public function unwrap_call_count(): int { return $this->unwrap_calls; } } diff --git a/tests/phpunit/includes/secrets/class-mock-store.php b/tests/phpunit/includes/secrets/class-mock-store.php index 0167c2d671835..53c33f686ae6b 100644 --- a/tests/phpunit/includes/secrets/class-mock-store.php +++ b/tests/phpunit/includes/secrets/class-mock-store.php @@ -33,7 +33,7 @@ class Mock_Store implements WP_Secrets_Store { */ private $received_records = array(); - public function get( $name, $network = false ) { + 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.' ); } @@ -43,7 +43,7 @@ public function get( $name, $network = false ) { return array_key_exists( $key, $this->records ) ? $this->records[ $key ] : null; } - public function set( $name, $record, $network = false ) { + public function set( string $name, array $record, bool $network = false ) { $this->received_records[] = $record; if ( $this->fail['set'] ) { @@ -55,7 +55,7 @@ public function set( $name, $record, $network = false ) { return true; } - public function delete( $name, $network = false ) { + 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.' ); } @@ -65,7 +65,7 @@ public function delete( $name, $network = false ) { return true; } - public function list_names( $network = false ) { + 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.' ); } @@ -88,7 +88,7 @@ public function list_names( $network = false ) { * * @return $this */ - public function configure_fail( $operation, $fail = true ) { + public function configure_fail( string $operation, bool $fail = true ): self { $this->fail[ $operation ] = $fail; return $this; @@ -101,7 +101,7 @@ public function configure_fail( $operation, $fail = true ) { * * @return array> */ - public function get_received_records() { + public function get_received_records(): array { return $this->received_records; } @@ -110,7 +110,7 @@ public function get_received_records() { * @param bool $network Whether this is a network-scope secret. * @return string The key the record is held under. */ - private function key( $name, $network ) { + private function key( string $name, bool $network ): string { return ( $network ? 'network:' : 'site:' ) . $name; } } diff --git a/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php b/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php index 0f4ba638678c6..bcbac6a69dda5 100644 --- a/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php +++ b/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php @@ -50,6 +50,8 @@ public function assertIsSecret( $maybe_secret, $expected = null, $message = '' ) * @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 ); diff --git a/tests/phpunit/tests/secrets/networkScope.php b/tests/phpunit/tests/secrets/networkScope.php index 5a244ad6dd2d5..6875bec0e19fd 100644 --- a/tests/phpunit/tests/secrets/networkScope.php +++ b/tests/phpunit/tests/secrets/networkScope.php @@ -115,7 +115,7 @@ public function test_list_returns_network_secrets_only(): void { 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' ); + $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() ); diff --git a/tests/phpunit/tests/secrets/provider.php b/tests/phpunit/tests/secrets/provider.php index edb2fa22cc8e8..6af6d7591789a 100644 --- a/tests/phpunit/tests/secrets/provider.php +++ b/tests/phpunit/tests/secrets/provider.php @@ -21,7 +21,7 @@ private function read_only_provider() { * @param bool $network Whether this is a network-scope secret. * @return WP_Secret|null|WP_Error */ - public function get( $name, $version, $network = false ) { + public function get( string $name, string $version, bool $network = false ) { return null; } @@ -33,7 +33,7 @@ public function get( $name, $version, $network = false ) { * @param string|null $action Overrides the action reported to `wp_secret_changed`. * @return WP_Error Always: this provider is read-only. */ - public function set( $name, $value, $network = false, $needs_rotation = false, $action = null ) { + 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.' @@ -45,7 +45,7 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ * @param bool $network Whether this is a network-scope secret. * @return WP_Error Always: this provider is read-only. */ - public function delete( $name, $network = false ) { + public function delete( string $name, bool $network = false ) { return new WP_Error( WP_SECRETS_ERROR_PROVIDER_READ_ONLY, 'Read-only.' ); } @@ -54,7 +54,7 @@ public function delete( $name, $network = false ) { * @param bool $network Whether this is a network-scope secret. * @return true Always: this provider keeps no version history. */ - public function retire_previous( $name, $network = false ) { + public function retire_previous( string $name, bool $network = false ) { return true; } @@ -65,28 +65,28 @@ public function retire_previous( $name, $network = false ) { * * @phpstan-return list */ - public function list_secrets( $name_prefix = '', $network = false ) { + public function list_secrets( string $name_prefix = '', bool $network = false ) { return array(); } /** * @return string */ - public function get_label() { + public function get_label(): string { return 'Example Platform (KMS)'; } /** * @return string */ - public function get_protection_boundary() { + public function get_protection_boundary(): string { return self::BOUNDARY_PROVIDER; } /** * @return bool */ - public function is_writable() { + public function is_writable(): bool { return false; } }; diff --git a/tests/phpunit/tests/secrets/readonlyProviderConformance.php b/tests/phpunit/tests/secrets/readonlyProviderConformance.php index 1aeb487473077..c93464ccea92b 100644 --- a/tests/phpunit/tests/secrets/readonlyProviderConformance.php +++ b/tests/phpunit/tests/secrets/readonlyProviderConformance.php @@ -24,7 +24,7 @@ protected function provider() { * @param bool $network Whether this is a network-scope secret. * @return WP_Secret|null|WP_Error */ - public function get( $name, $version, $network = false ) { + public function get( string $name, string $version, bool $network = false ) { return null; } @@ -36,7 +36,7 @@ public function get( $name, $version, $network = false ) { * @param string|null $action Overrides the action reported to `wp_secret_changed`. * @return WP_Error Always: this provider is read-only. */ - public function set( $name, $value, $network = false, $needs_rotation = false, $action = null ) { + 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.' @@ -48,7 +48,7 @@ public function set( $name, $value, $network = false, $needs_rotation = false, $ * @param bool $network Whether this is a network-scope secret. * @return WP_Error Always: this provider is read-only. */ - public function delete( $name, $network = false ) { + public function delete( string $name, bool $network = false ) { return new WP_Error( WP_SECRETS_ERROR_PROVIDER_READ_ONLY, 'Read-only.' ); } @@ -57,7 +57,7 @@ public function delete( $name, $network = false ) { * @param bool $network Whether this is a network-scope secret. * @return WP_Error Always: this provider is read-only. */ - public function retire_previous( $name, $network = false ) { + public function retire_previous( string $name, bool $network = false ) { return new WP_Error( WP_SECRETS_ERROR_PROVIDER_READ_ONLY, 'Read-only.' ); } @@ -68,28 +68,28 @@ public function retire_previous( $name, $network = false ) { * * @phpstan-return list */ - public function list_secrets( $name_prefix = '', $network = false ) { + public function list_secrets( string $name_prefix = '', bool $network = false ) { return array(); } /** * @return string */ - public function get_label() { + public function get_label(): string { return 'Example Platform (control panel)'; } /** * @return string */ - public function get_protection_boundary() { + public function get_protection_boundary(): string { return self::BOUNDARY_PROVIDER; } /** * @return bool */ - public function is_writable() { + public function is_writable(): bool { return false; } }; diff --git a/tests/phpunit/tests/secrets/wpListSecrets.php b/tests/phpunit/tests/secrets/wpListSecrets.php index 01e6878d96916..cb2752ed9eb3b 100644 --- a/tests/phpunit/tests/secrets/wpListSecrets.php +++ b/tests/phpunit/tests/secrets/wpListSecrets.php @@ -109,15 +109,6 @@ public function test_namespace_does_not_match_a_prefix_of_a_different_namespace( $this->assertSame( array(), wp_list_secrets( 'plugin' ) ); } - public function test_a_non_string_namespace_is_a_wp_error_not_an_exception(): void { - $this->setExpectedIncorrectUsage( '_wp_secrets_list' ); - - $result = wp_list_secrets( array( 'not', 'a', 'string' ) ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.) - - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); - } - /** * A corrupted record must still appear in the list -- Site Health's * undecryptable-secrets check depends on being able to see it exists. diff --git a/tests/phpunit/tests/secrets/wpSecret.php b/tests/phpunit/tests/secrets/wpSecret.php index 5a1b2dcab8f4c..6154399e378d5 100644 --- a/tests/phpunit/tests/secrets/wpSecret.php +++ b/tests/phpunit/tests/secrets/wpSecret.php @@ -336,12 +336,6 @@ public function test_withheld_masks_like_any_other_secret(): void { $this->assertStringContainsString( '[secret:myplugin/signing-key]', (string) $secret ); } - public function test_withheld_requires_a_wp_error_reason(): void { - $this->expectException( InvalidArgumentException::class ); - - WP_Secret::withheld( 'myplugin/signing-key', 'abc123', 'not an error' ); // @phpstan-ignore argument.type (Intentionally passing a reason that is not a WP_Error.) - } - /** * An ordinary secret is unaffected: reveal() still returns the plaintext, and * the widened return type is not a behaviour change for the shipped provider. diff --git a/tests/phpunit/tests/secrets/wpSecretsCipher.php b/tests/phpunit/tests/secrets/wpSecretsCipher.php index 4977682e0e9ef..c60e344663686 100644 --- a/tests/phpunit/tests/secrets/wpSecretsCipher.php +++ b/tests/phpunit/tests/secrets/wpSecretsCipher.php @@ -76,26 +76,25 @@ public function test_encrypt_rejects_a_non_string_value(): void { /** * @dataProvider data_bad_master_keys * - * @param mixed $bad_key Master key the cipher must refuse. + * @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' ); // @phpstan-ignore argument.type (Intentionally passing an invalid master key, including a non-string.) + $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 + * @return array */ public function data_bad_master_keys() { return array( - 'too short' => array( str_repeat( 'a', 31 ) ), - 'too long' => array( str_repeat( 'a', 33 ) ), - 'empty' => array( '' ), - 'not a string' => array( 12345 ), + 'too short' => array( str_repeat( 'a', 31 ) ), + 'too long' => array( str_repeat( 'a', 33 ) ), + 'empty' => array( '' ), ); } @@ -113,7 +112,7 @@ public function test_encrypt_reports_an_invalid_scope_as_a_wp_error(): void { $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' ); + $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() ); @@ -135,7 +134,7 @@ public function test_encrypt_reports_an_invalid_slot_as_a_wp_error(): void { $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' ); + $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() ); @@ -151,6 +150,9 @@ public function test_encrypt_reports_an_invalid_slot_as_a_wp_error(): void { * @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(); diff --git a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php index 6d0dcfcf8ac58..b19a9941ef049 100644 --- a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php +++ b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php @@ -45,15 +45,6 @@ public function test_wrap_then_unwrap_round_trips(): void { $this->assertSame( $material, $unwrapped ); } - public function test_wrap_rejects_a_non_string(): void { - $provider = new WP_Secrets_Config_Key_Provider(); - - $result = $provider->wrap( 12345 ); // @phpstan-ignore argument.type (Intentionally passing a non-string to exercise the runtime guard.) - - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_VALUE, $result->get_error_code() ); - } - public function test_wrap_rejects_an_empty_string(): void { $provider = new WP_Secrets_Config_Key_Provider(); @@ -70,10 +61,10 @@ public function test_two_wraps_of_the_same_material_produce_different_output(): $this->assertNotSame( $provider->wrap( $material ), $provider->wrap( $material ) ); } - public function test_unwrap_rejects_a_non_string(): void { + public function test_unwrap_rejects_an_empty_string(): void { $provider = new WP_Secrets_Config_Key_Provider(); - $result = $provider->unwrap( array() ); // @phpstan-ignore argument.type (Intentionally passing a non-string to exercise the runtime guard.) + $result = $provider->unwrap( '' ); $this->assertWPError( $result ); $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); diff --git a/tests/phpunit/tests/secrets/wpSecretsKeyManager.php b/tests/phpunit/tests/secrets/wpSecretsKeyManager.php index 3b55b31757f1e..8df87209afa21 100644 --- a/tests/phpunit/tests/secrets/wpSecretsKeyManager.php +++ b/tests/phpunit/tests/secrets/wpSecretsKeyManager.php @@ -96,7 +96,7 @@ public function test_get_master_key_reports_an_invalid_scope_as_a_wp_error(): vo $this->setExpectedIncorrectUsage( 'WP_Secrets_Key_Manager::get_master_key' ); - $result = $manager->get_master_key( 'bogus' ); + $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() ); @@ -105,27 +105,26 @@ public function test_get_master_key_reports_an_invalid_scope_as_a_wp_error(): vo /** * @dataProvider data_invalid_site_ids * - * @param int|string $site_id A value that is not a usable blog id. + * @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 ); // @phpstan-ignore argument.type (The data provider intentionally includes a non-integer site id.) + $result = $manager->get_master_key( 'site', $site_id ); $this->assertWPError( $result ); $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); } /** - * @return array + * @return array */ public function data_invalid_site_ids(): array { return array( 'zero' => array( 0 ), 'negative' => array( -1 ), - 'string' => array( 'five' ), ); } @@ -281,7 +280,7 @@ public function test_rotate_fails_when_the_old_keyring_is_wrong(): void { * @param string $key_material Raw key material. * @return string */ - public function wrap( $key_material ) { + public function wrap( string $key_material ) { return base64_encode( $key_material ); } @@ -289,14 +288,14 @@ public function wrap( $key_material ) { * @param string $wrapped Wrapped value. * @return WP_Error */ - public function unwrap( $wrapped ) { + public function unwrap( string $wrapped ) { return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, 'wrong key' ); } /** * @return string */ - public function get_key_source() { + public function get_key_source(): string { return 'test double'; } }; diff --git a/tests/phpunit/tests/secrets/wpSecretsOptionStore.php b/tests/phpunit/tests/secrets/wpSecretsOptionStore.php index e191149d70d60..eb6e3535a4d1c 100644 --- a/tests/phpunit/tests/secrets/wpSecretsOptionStore.php +++ b/tests/phpunit/tests/secrets/wpSecretsOptionStore.php @@ -63,15 +63,6 @@ public function test_site_and_network_scope_do_not_collide(): void { $this->assertSame( $network_record, $store->get( 'myplugin/key', true ) ); } - public function test_set_rejects_a_non_array_record(): void { - $store = new WP_Secrets_Option_Store(); - - $result = $store->set( 'myplugin/key', 'not an array' ); // @phpstan-ignore argument.type (Intentionally passing a non-array record to exercise the runtime guard.) - - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() ); - } - 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 ); diff --git a/tests/phpunit/tests/secrets/wpSecretsValidateName.php b/tests/phpunit/tests/secrets/wpSecretsValidateName.php index ba3303b1cd6d4..16e38473ee026 100644 --- a/tests/phpunit/tests/secrets/wpSecretsValidateName.php +++ b/tests/phpunit/tests/secrets/wpSecretsValidateName.php @@ -32,10 +32,10 @@ public function data_valid_names(): array { /** * @dataProvider data_invalid_names * - * @param mixed $name A value that must be rejected; not always a string. + * @param string $name A name that must be rejected. */ public function test_rejects_invalid_names( $name ): void { - $result = wp_secrets_validate_name( $name ); // @phpstan-ignore argument.type (The data provider intentionally includes non-string values.) + $result = wp_secrets_validate_name( $name ); $this->assertWPError( $result ); $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); @@ -82,7 +82,7 @@ public function test_an_unnamespaced_name_still_obeys_the_character_rules(): voi } /** - * @return array + * @return array */ public function data_invalid_names(): array { return array( @@ -101,10 +101,6 @@ public function data_invalid_names(): array { 'space in key' => array( 'plugin/key with space' ), 'dot in key' => array( 'plugin/key.name' ), 'non-ascii' => array( 'plügin/key' ), - 'not a string: null' => array( null ), - 'not a string: int' => array( 12345 ), - 'not a string: array' => array( array( 'plugin/key' ) ), - 'not a string: bool' => array( true ), 'one character over the max' => array( str_repeat( 'a', 85 ) . '/' . str_repeat( 'b', 87 ) ), ); } diff --git a/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php b/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php index 9c0a692fbbd72..a7943aee78067 100644 --- a/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php +++ b/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php @@ -49,7 +49,7 @@ public function test_get_previous_on_a_never_rotated_secret_is_null_not_error(): 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' ); + $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() ); From e7224dea81333227700ec331e72eeb68ad4813ea Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 9 Oct 2026 12:59:20 -0700 Subject: [PATCH 11/12] Secrets API: use an en dash in comments, and realign docblocks Comments used `--` as a dash. They now use an en dash, and the two messages that had one are reworded. Ten docblock continuation lines were indented one column too far. See #66187. --- src/wp-admin/includes/secrets-site-health.php | 10 +++--- src/wp-includes/secrets.php | 4 +-- src/wp-includes/secrets/class-wp-secret.php | 14 ++++---- .../class-wp-secrets-broken-provider.php | 2 +- .../secrets/class-wp-secrets-broken-store.php | 2 +- .../secrets/class-wp-secrets-cipher.php | 8 ++--- .../class-wp-secrets-config-key-provider.php | 10 +++--- .../secrets/class-wp-secrets-key-manager.php | 6 ++-- .../class-wp-secrets-libsodium-provider.php | 34 +++++++++---------- .../secrets/class-wp-secrets-option-store.php | 4 +-- .../includes/secrets/class-mock-keyring.php | 2 +- .../includes/secrets/class-mock-store.php | 2 +- .../class-wp-secrets-keyring-conformance.php | 12 +++---- .../class-wp-secrets-provider-conformance.php | 20 +++++------ .../secrets/trait-wp-secrets-assertions.php | 2 +- .../tests/secrets/brokenDropinFallbacks.php | 2 +- tests/phpunit/tests/secrets/errorCodes.php | 2 +- .../phpunit/tests/secrets/extensionPoints.php | 8 ++--- tests/phpunit/tests/secrets/provider.php | 12 +++---- tests/phpunit/tests/secrets/siteHealth.php | 2 +- .../tests/secrets/threeStateContract.php | 8 ++--- tests/phpunit/tests/secrets/versionSlots.php | 4 +-- .../phpunit/tests/secrets/wpDeleteSecret.php | 2 +- .../tests/secrets/wpImportOptionAsSecret.php | 2 +- tests/phpunit/tests/secrets/wpListSecrets.php | 6 ++-- tests/phpunit/tests/secrets/wpSecret.php | 8 ++--- .../phpunit/tests/secrets/wpSecretsCipher.php | 2 +- .../tests/secrets/wpSecretsKeyManager.php | 4 +-- .../tests/secrets/wpSecretsMemzero.php | 2 +- .../tests/secrets/wpSecretsOptionStore.php | 2 +- .../tests/secrets/wpSecretsValidateName.php | 6 ++-- 31 files changed, 102 insertions(+), 102 deletions(-) diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index 018ac2085ea10..65ffe288699eb 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -10,7 +10,7 @@ /** * Registers the Secrets API's Site Health tests. * - * No settings screen -- the proposal defers that to 7.3 -- but the health signal an + * 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. * @@ -45,7 +45,7 @@ function wp_secrets_site_health_tests( $tests ) { * @since 7.2.0 * * @param string $test The test's own identifier, matching the key registered - * in wp_secrets_site_health_tests(). + * 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

    elements. @@ -132,7 +132,7 @@ function wp_secrets_site_health_test_key_source(): array { /** * Site Health test: does every stored secret still decrypt? * - * Network secrets are included only for a super admin on a multisite install -- + * 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 @@ -177,7 +177,7 @@ function wp_secrets_site_health_test_undecryptable(): array { 'critical', sprintf( '

    %s

      %s

    %s

    ', - 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.' ), + 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.' ) ) @@ -294,7 +294,7 @@ function wp_secrets_site_health_count_needing_rotation( bool $network ): int { /** * Adds a Secrets API section to Site Health's debug information. * - * Counts and class names only -- no secret values, and no fingerprints. Network + * 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 diff --git a/src/wp-includes/secrets.php b/src/wp-includes/secrets.php index b8a7176fcabba..99d87ed0d7413 100644 --- a/src/wp-includes/secrets.php +++ b/src/wp-includes/secrets.php @@ -535,7 +535,7 @@ function wp_retire_secret_version( string $name ) { * @since 7.2.0 * * @param string $namespace Only secrets whose name starts with "{$namespace}/" are - * returned. Default '' returns every secret. + * returned. Default '' returns every secret. * @return array|WP_Error Array of associative arrays, each with keys 'name', * 'fingerprint', 'created', 'has_previous', and * 'needs_rotation'. @@ -609,7 +609,7 @@ function wp_retire_network_secret_version( string $name ) { * @since 7.2.0 * * @param string $namespace Only secrets whose name starts with "{$namespace}/" are - * returned. Default '' returns every network-scope secret. + * returned. Default '' returns every network-scope secret. * @return array|WP_Error * * @phpstan-return list|WP_Error diff --git a/src/wp-includes/secrets/class-wp-secret.php b/src/wp-includes/secrets/class-wp-secret.php index 2e725d6cfb05b..260990c3a65ed 100644 --- a/src/wp-includes/secrets/class-wp-secret.php +++ b/src/wp-includes/secrets/class-wp-secret.php @@ -11,7 +11,7 @@ * 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 + * 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 @@ -30,7 +30,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 + * 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. @@ -62,7 +62,7 @@ final class WP_Secret implements JsonSerializable { /** * 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 -- + * 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, @@ -80,7 +80,7 @@ final class WP_Secret implements JsonSerializable { * 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 + * 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. @@ -122,7 +122,7 @@ public function __construct( string $name, $value, string $fingerprint ) { * 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 + * 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 @@ -146,7 +146,7 @@ public function reveal() { * 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 + * 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. @@ -314,7 +314,7 @@ public function __unserialize( array $data ): void { * 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/src/wp-includes/secrets/class-wp-secrets-broken-provider.php b/src/wp-includes/secrets/class-wp-secrets-broken-provider.php index f2eed41eaee27..8c429c55e0705 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-provider.php @@ -11,7 +11,7 @@ * 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 + * 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. diff --git a/src/wp-includes/secrets/class-wp-secrets-broken-store.php b/src/wp-includes/secrets/class-wp-secrets-broken-store.php index 82b1228a18482..31e64d8e5c7fc 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-store.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-store.php @@ -14,7 +14,7 @@ * 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 + * 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. diff --git a/src/wp-includes/secrets/class-wp-secrets-cipher.php b/src/wp-includes/secrets/class-wp-secrets-cipher.php index 0076bfacbf09a..4dc631597ca3b 100644 --- a/src/wp-includes/secrets/class-wp-secrets-cipher.php +++ b/src/wp-includes/secrets/class-wp-secrets-cipher.php @@ -202,7 +202,7 @@ public function decrypt_value( string $master_key, string $scope, int $site_id, * 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 + * 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 @@ -249,7 +249,7 @@ public function fingerprint( string $master_key, $plaintext ) { * * 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 + * wp_secrets_validate_name() – the validated character set contains no '|', which * is what makes this delimiter safe to join on. * * @since 7.2.0 @@ -271,8 +271,8 @@ private function build_aad( string $purpose, string $scope, int $site_id, string /** * 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 + * $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 diff --git a/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php b/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php index a56e4582c1fd1..7fbd208818ea6 100644 --- a/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php @@ -19,12 +19,12 @@ * 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 -- + * 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 + * 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 @@ -180,7 +180,7 @@ 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)'; + : 'WP_SECRETS_KEY (legacy interpretation, hashed as an opaque string)'; } return 'derived from LOGGED_IN_KEY and LOGGED_IN_SALT'; @@ -243,12 +243,12 @@ private function get_site_key() { * 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, + * 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 + * which – once WordPress has bootstrapped once in a process – can never be * redefined to a different value for a test. * * @since 7.2.0 diff --git a/src/wp-includes/secrets/class-wp-secrets-key-manager.php b/src/wp-includes/secrets/class-wp-secrets-key-manager.php index e066042f094f5..1cd92a3837023 100644 --- a/src/wp-includes/secrets/class-wp-secrets-key-manager.php +++ b/src/wp-includes/secrets/class-wp-secrets-key-manager.php @@ -28,7 +28,7 @@ * * 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 + * 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. @@ -140,7 +140,7 @@ public function get_keyring(): WP_Secrets_Keyring { * 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 @@ -271,7 +271,7 @@ public function rotate_site_key( WP_Secrets_Keyring $old_keyring, WP_Secrets_Key /* * update_site_option() returns false both on genuine failure and when the - * new value equals the old one -- a documented ambiguity shared with + * 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, diff --git a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php index 5747dcb6e9eb1..d7dc205cf90a2 100644 --- a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php @@ -22,8 +22,8 @@ * 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. + * - 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 @@ -157,7 +157,7 @@ private function validate_record_shape( $record ) { * 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 + * 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 @@ -285,7 +285,7 @@ private function demote_slot( WP_Secrets_Cipher $cipher, string $master_key, str * 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 -- + * usual 'created'/'updated' detection – * wp_import_option_as_secret() passes 'imported'. * @return true|WP_Error */ @@ -339,19 +339,19 @@ public function set( string $name, $value, bool $network = false, bool $needs_ro /* * Demotion: the outgoing current slot becomes the new previous slot. Only - * one previous slot is ever kept -- whatever was already in + * 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 + * 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 + * 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. @@ -385,7 +385,7 @@ public function set( string $name, $value, bool $network = false, bool $needs_ro * * @param string $name The secret's namespaced name. * @param string $action One of 'created', 'updated', 'deleted', - * 'imported', 'retired'. + * '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. @@ -444,7 +444,7 @@ public function get( string $name, string $version, bool $network = false ) { } if ( ! isset( $record[ $version ] ) || ! is_array( $record[ $version ] ) ) { - // The secret exists, but this slot does not. Absence, not an error -- + // 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; } @@ -472,7 +472,7 @@ public function get( string $name, string $version, bool $network = false ) { * 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 + * 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. @@ -572,7 +572,7 @@ public function retire_previous( string $name, bool $network = false ) { if ( null === $record || ! isset( $record['previous'] ) ) { /* - * The goal of retirement -- "there is no previous slot" -- is already + * 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 @@ -616,7 +616,7 @@ public function retire_previous( string $name, bool $network = false ) { * * 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 + * 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 @@ -626,10 +626,10 @@ public function retire_previous( string $name, bool $network = false ) { * @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. + * 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 diff --git a/src/wp-includes/secrets/class-wp-secrets-option-store.php b/src/wp-includes/secrets/class-wp-secrets-option-store.php index 3f849948a7bb1..7ffc1496528d6 100644 --- a/src/wp-includes/secrets/class-wp-secrets-option-store.php +++ b/src/wp-includes/secrets/class-wp-secrets-option-store.php @@ -15,7 +15,7 @@ * * 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 -- + * 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. * @@ -91,7 +91,7 @@ public function set( string $name, array $record, bool $network = false ) { /* * update_option()/update_site_option() return false both on genuine - * failure and when the new value already equals the stored one -- a + * 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. */ diff --git a/tests/phpunit/includes/secrets/class-mock-keyring.php b/tests/phpunit/includes/secrets/class-mock-keyring.php index e6e72732443dc..159f0b108ec5d 100644 --- a/tests/phpunit/includes/secrets/class-mock-keyring.php +++ b/tests/phpunit/includes/secrets/class-mock-keyring.php @@ -1,6 +1,6 @@ get( $name, WP_Secret_Version::PREVIOUS ) instanceof WP_Secret; } - // -- declarations must be coherent ---------------------------------------- + // – declarations must be coherent ---------------------------------------- public function test_reports_a_non_empty_label(): void { $label = $this->provider()->get_label(); @@ -142,11 +142,11 @@ public function test_a_read_only_provider_actually_refuses_writes(): void { ); } - // -- absence is null, and only null --------------------------------------- + // – 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 + * 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. */ @@ -194,7 +194,7 @@ public function test_retiring_with_no_previous_version_is_success(): void { $this->assertNotWPError( $provider->retire_previous( $name ) ); } - // -- the round trip -------------------------------------------------------- + // – the round trip -------------------------------------------------------- public function test_a_stored_value_comes_back_intact(): void { $provider = $this->provider(); @@ -247,7 +247,7 @@ public function test_a_deleted_secret_reads_as_absent(): void { $this->assertNull( $provider->get( $name, WP_Secret_Version::CURRENT ) ); } - // -- every change is reported ---------------------------------------------- + // – every change is reported ---------------------------------------------- /** * The API does not fire `wp_secret_changed` on a provider's behalf, so a @@ -368,7 +368,7 @@ public function test_a_refused_write_reports_no_change(): void { $this->assertCount( 0, $this->recorded_changes, 'A write that did not happen must not be reported as a change.' ); } - // -- listing never leaks --------------------------------------------------- + // – listing never leaks --------------------------------------------------- public function test_listing_returns_an_array_or_an_error_and_never_a_value(): void { $provider = $this->provider(); diff --git a/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php b/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php index bcbac6a69dda5..504d6dde591f1 100644 --- a/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php +++ b/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php @@ -28,7 +28,7 @@ public function assertIsSecret( $maybe_secret, $expected = null, $message = '' ) $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->fail( $context . 'Expected a WP_Secret, got WP_Error: ' . $maybe_secret->get_error_code() . ': ' . $maybe_secret->get_error_message() ); } $this->assertInstanceOf( diff --git a/tests/phpunit/tests/secrets/brokenDropinFallbacks.php b/tests/phpunit/tests/secrets/brokenDropinFallbacks.php index 4836b59ef0fde..77b2875d79149 100644 --- a/tests/phpunit/tests/secrets/brokenDropinFallbacks.php +++ b/tests/phpunit/tests/secrets/brokenDropinFallbacks.php @@ -69,7 +69,7 @@ public function test_wp_using_secrets_dropin_is_false_by_default(): void { */ 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 + // 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; diff --git a/tests/phpunit/tests/secrets/errorCodes.php b/tests/phpunit/tests/secrets/errorCodes.php index e529525478c29..9d5dc6f0e9ce4 100644 --- a/tests/phpunit/tests/secrets/errorCodes.php +++ b/tests/phpunit/tests/secrets/errorCodes.php @@ -1,7 +1,7 @@ assertInstanceOf( 'WP_Secrets_Libsodium_Provider', _wp_secrets_get_provider() ); @@ -124,7 +124,7 @@ public function test_the_default_provider_label_names_the_key_source(): void { ); } - // -- a platform provider, which is what the seam exists for ---------------- + // – a platform provider, which is what the seam exists for ---------------- /** * @runInSeparateProcess @@ -160,7 +160,7 @@ public function test_a_read_only_provider_refuses_writes_and_declares_it(): void } /** - * Reads still work on a read-only provider -- "read-only" is about writes, not + * Reads still work on a read-only provider – "read-only" is about writes, not * about being broken. Absence is still absence. * * @runInSeparateProcess @@ -172,7 +172,7 @@ public function test_a_read_only_provider_still_serves_reads(): void { $this->assertNull( wp_get_secret( 'myplugin/api-key' ) ); } - // -- fail closed ----------------------------------------------------------- + // – fail closed ----------------------------------------------------------- /** * A broken drop-in must not quietly revert to the default provider: that would @@ -220,7 +220,7 @@ public function test_a_broken_dropin_read_is_an_error_not_an_absence(): void { /** * 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, + * 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. * diff --git a/tests/phpunit/tests/secrets/siteHealth.php b/tests/phpunit/tests/secrets/siteHealth.php index de1dd9bc9656a..d0eee49ceb421 100644 --- a/tests/phpunit/tests/secrets/siteHealth.php +++ b/tests/phpunit/tests/secrets/siteHealth.php @@ -218,7 +218,7 @@ public function test_debug_info_omits_network_fields_on_single_site(): void { } /** - * Network secret counts appear only for a super admin on a multisite install -- + * 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 { diff --git a/tests/phpunit/tests/secrets/threeStateContract.php b/tests/phpunit/tests/secrets/threeStateContract.php index 74ce17eb31175..acf2666ecb54f 100644 --- a/tests/phpunit/tests/secrets/threeStateContract.php +++ b/tests/phpunit/tests/secrets/threeStateContract.php @@ -68,8 +68,8 @@ public function test_unsupported_record_version_is_wp_error_not_null(): void { /** * 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 + * 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 { @@ -121,7 +121,7 @@ public function test_key_unavailable_is_wp_error_not_null(): void { /** * Written under the ambient salt-fallback key, then read back after the stored - * wrapped root key has been corrupted -- simulating the option row being + * 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. */ @@ -142,7 +142,7 @@ public function test_a_corrupted_wrapped_root_key_is_wp_error_not_null(): void { * '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 + * 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 { diff --git a/tests/phpunit/tests/secrets/versionSlots.php b/tests/phpunit/tests/secrets/versionSlots.php index e7fdcec739981..274853b8699e0 100644 --- a/tests/phpunit/tests/secrets/versionSlots.php +++ b/tests/phpunit/tests/secrets/versionSlots.php @@ -2,7 +2,7 @@ /** * 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 + * "the single most important new test in the suite" – the prior proof-of-concept * could not do this at all. * * @group secrets @@ -129,7 +129,7 @@ public function test_previous_on_a_secret_with_no_previous_slot_is_null(): void /** * 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 + * 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 { diff --git a/tests/phpunit/tests/secrets/wpDeleteSecret.php b/tests/phpunit/tests/secrets/wpDeleteSecret.php index 485cf5b22a771..83b9bb3fc9f13 100644 --- a/tests/phpunit/tests/secrets/wpDeleteSecret.php +++ b/tests/phpunit/tests/secrets/wpDeleteSecret.php @@ -70,7 +70,7 @@ function () use ( &$fired ) { /** * A corrupted record must still be removable. Refusing here would leave a secret - * permanently stuck -- unreadable, unrepairable, and undeletable without editing + * permanently stuck – unreadable, unrepairable, and undeletable without editing * the database by hand. */ public function test_a_corrupt_record_is_still_deletable(): void { diff --git a/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php b/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php index 4b7e04d0878eb..a6051be7f9524 100644 --- a/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php +++ b/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php @@ -97,7 +97,7 @@ function ( ...$args ) use ( &$captured ) { /** * An import onto an existing secret behaves like any other write, including - * demotion -- there is nothing special about the destination just because the + * 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 { diff --git a/tests/phpunit/tests/secrets/wpListSecrets.php b/tests/phpunit/tests/secrets/wpListSecrets.php index cb2752ed9eb3b..a088eb3156a0c 100644 --- a/tests/phpunit/tests/secrets/wpListSecrets.php +++ b/tests/phpunit/tests/secrets/wpListSecrets.php @@ -25,7 +25,7 @@ public function test_lists_every_secret_with_the_expected_keys(): void { } /** - * Never a value, under any circumstance -- the entire justification for this + * Never a value, under any circumstance – the entire justification for this * function existing depends on it. */ public function test_never_returns_a_value(): void { @@ -103,14 +103,14 @@ public function test_empty_namespace_returns_everything(): void { } 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. + // '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 + * 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 { diff --git a/tests/phpunit/tests/secrets/wpSecret.php b/tests/phpunit/tests/secrets/wpSecret.php index 6154399e378d5..2418044531fab 100644 --- a/tests/phpunit/tests/secrets/wpSecret.php +++ b/tests/phpunit/tests/secrets/wpSecret.php @@ -183,7 +183,7 @@ public function test_clone_throws(): void { /** * 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. + * pair PHP's engine prefers – this proves each of the four refuses independently. * * @dataProvider data_refusing_magic_methods * @@ -257,7 +257,7 @@ public function test_wp_cache_set_of_a_secret_is_refused(): void { * 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 + * 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 @@ -290,12 +290,12 @@ public function test_two_instances_do_not_share_a_vault_slot(): void { $this->assertSame( 'value-b', $b->reveal() ); } - // -- withheld secrets (provider does not release the value) --------------- + // – 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 + * 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 { diff --git a/tests/phpunit/tests/secrets/wpSecretsCipher.php b/tests/phpunit/tests/secrets/wpSecretsCipher.php index c60e344663686..63b8eec0a0bc3 100644 --- a/tests/phpunit/tests/secrets/wpSecretsCipher.php +++ b/tests/phpunit/tests/secrets/wpSecretsCipher.php @@ -361,7 +361,7 @@ public function test_fingerprint_known_answer_vector(): void { /** * 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. + * 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(); diff --git a/tests/phpunit/tests/secrets/wpSecretsKeyManager.php b/tests/phpunit/tests/secrets/wpSecretsKeyManager.php index 8df87209afa21..a42f6078bd435 100644 --- a/tests/phpunit/tests/secrets/wpSecretsKeyManager.php +++ b/tests/phpunit/tests/secrets/wpSecretsKeyManager.php @@ -130,7 +130,7 @@ public function data_invalid_site_ids(): array { /** * 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 -- + * 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. @@ -307,7 +307,7 @@ public function get_key_source(): string { /** * 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. + * 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. * diff --git a/tests/phpunit/tests/secrets/wpSecretsMemzero.php b/tests/phpunit/tests/secrets/wpSecretsMemzero.php index 18a6b0f6930e7..3df026b4294ca 100644 --- a/tests/phpunit/tests/secrets/wpSecretsMemzero.php +++ b/tests/phpunit/tests/secrets/wpSecretsMemzero.php @@ -36,7 +36,7 @@ public function test_does_not_throw_when_the_value_is_shared(): void { wp_secrets_memzero( $value ); $this->assertSame( '', $value ); - // The shared copy is unaffected -- see the docblock on wp_secrets_memzero(): + // 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.) } diff --git a/tests/phpunit/tests/secrets/wpSecretsOptionStore.php b/tests/phpunit/tests/secrets/wpSecretsOptionStore.php index eb6e3535a4d1c..2c612624a8798 100644 --- a/tests/phpunit/tests/secrets/wpSecretsOptionStore.php +++ b/tests/phpunit/tests/secrets/wpSecretsOptionStore.php @@ -154,7 +154,7 @@ public function test_list_names_does_not_include_network_scope_names(): void { */ 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. + // any single character – proving esc_like() is actually applied. update_option( '_wpXsecret_decoy/name', 'irrelevant value', false ); $store = new WP_Secrets_Option_Store(); diff --git a/tests/phpunit/tests/secrets/wpSecretsValidateName.php b/tests/phpunit/tests/secrets/wpSecretsValidateName.php index 16e38473ee026..0e097a8ccd000 100644 --- a/tests/phpunit/tests/secrets/wpSecretsValidateName.php +++ b/tests/phpunit/tests/secrets/wpSecretsValidateName.php @@ -43,8 +43,8 @@ public function test_rejects_invalid_names( $name ): void { /** * 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. + * 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 @@ -71,7 +71,7 @@ public function data_unnamespaced_names(): array { /** * The unnamespaced form relaxes the "exactly one slash" rule, not the - * character rules -- an unnamespaced name is still held to the same segment + * 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 { From 48fa17a83772e31e872050407198e7030ca9ce77 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 9 Oct 2026 13:24:01 -0700 Subject: [PATCH 12/12] Secrets API: drop a PHPStan ignore that 2.3.0 no longer needs Trunk moved to PHPStan 2.3.0, which no longer reports method.impossibleType on the assertion in the memzero test. With reportUnmatchedIgnoredErrors on, the leftover ignore was itself an error at level 10. See #66187. --- tests/phpunit/tests/secrets/wpSecretsMemzero.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/secrets/wpSecretsMemzero.php b/tests/phpunit/tests/secrets/wpSecretsMemzero.php index 3df026b4294ca..350a3fc6cf7ff 100644 --- a/tests/phpunit/tests/secrets/wpSecretsMemzero.php +++ b/tests/phpunit/tests/secrets/wpSecretsMemzero.php @@ -18,7 +18,7 @@ public function test_leaves_non_string_values_untouched(): void { wp_secrets_memzero( $value ); // @phpstan-ignore argument.type (Intentionally passing a non-string value.) - $this->assertSame( 42, $value ); // @phpstan-ignore method.impossibleType (The by-reference parameter is documented as a string; this checks a non-string comes back untouched.) + $this->assertSame( 42, $value ); } public function test_leaves_an_empty_string_untouched(): void {