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..5dc0b4325228f 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(): void { + $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/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php new file mode 100644 index 0000000000000..65ffe288699eb --- /dev/null +++ b/src/wp-admin/includes/secrets-site-health.php @@ -0,0 +1,367 @@ +>> $tests Existing Site Health tests. + * @return array>> + */ +function wp_secrets_site_health_tests( $tests ) { + $tests['direct']['secrets_api_key_source'] = array( + 'label' => __( 'Secrets API key source' ), + 'test' => 'wp_secrets_site_health_test_key_source', + ); + + $tests['direct']['secrets_api_undecryptable'] = array( + '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' ), + '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 + * + * @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( string $test, string $label, string $status, string $description ): array { + $colors = array( + 'good' => 'green', + 'recommended' => 'orange', + 'critical' => 'red', + ); + + return array( + 'test' => $test, + 'label' => $label, + 'status' => $status, + 'badge' => array( + 'label' => __( 'Security' ), + '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 + * + * @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(): array { + $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' ), + 'critical', + sprintf( + '

%s

', + esc_html__( 'The active keyring did not load correctly. No secret can be encrypted or decrypted until this is fixed.' ) + ) + ); + } + + if ( false !== strpos( $key_source, 'legacy interpretation' ) ) { + return wp_secrets_site_health_result( + 'secrets_api_key_source', + __( '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`.' ) + ) + ); + } + + 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' ), + '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`.' ) + ) + ); + } + + return wp_secrets_site_health_result( + 'secrets_api_key_source', + __( 'Secrets API is using a dedicated key' ), + '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 + * + * @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(): array { + $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' ), + 'good', + sprintf( '

%s

', esc_html__( 'Every stored secret decrypted successfully.' ) ) + ); + } + + $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' ), + esc_html( $entry['fingerprint'] ) + ) + ); + } + + return wp_secrets_site_health_result( + 'secrets_api_undecryptable', + __( 'Some secrets cannot be decrypted' ), + '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.' ), + $items, + esc_html__( 'This can happen after losing WP_SECRETS_KEY, restoring a database backup without its matching key, or a failed key rotation.' ) + ) + ); +} + +/** + * 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' => ... ). + * + * @phpstan-return list + */ +function wp_secrets_site_health_find_undecryptable( bool $network ): array { + $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 + * + * @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(): array { + $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' ), + 'good', + 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' ), + '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 + ), + $count + ) + ) + ) + ); +} + +/** + * Counts secrets flagged needs_rotation, for one scope. + * + * @since 7.2.0 + * + * @param bool $network Whether to check network-scope secrets. + * @return int + * + * @phpstan-return int<0, max> + */ +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 ) ) { + 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' ), + 'value' => wp_using_secrets_dropin() ? __( 'Yes' ) : __( 'No' ), + ), + 'provider_class' => array( + 'label' => __( 'Provider class' ), + 'value' => get_class( $provider ), + ), + 'protected_by' => array( + 'label' => __( 'Secrets protected by' ), + 'value' => $provider->get_label(), + ), + 'protection_at' => array( + 'label' => __( 'Encryption boundary' ), + 'value' => WP_Secrets_Provider::BOUNDARY_WORDPRESS === $provider->get_protection_boundary() + ? __( 'WordPress' ) + : __( 'The provider (outside WordPress)' ), + ), + 'writable' => array( + 'label' => __( 'Accepts writes' ), + 'value' => $provider->is_writable() ? __( 'Yes' ) : __( 'No' ), + ), + 'keyring_class' => array( + 'label' => __( 'Keyring class' ), + 'value' => get_class( $key_manager->get_keyring() ), + ), + 'key_source' => array( + 'label' => __( 'Key source' ), + 'value' => $key_manager->get_keyring()->get_key_source(), + ), + 'record_version' => array( + 'label' => __( 'Record format version' ), + 'value' => (string) WP_SECRETS_RECORD_VERSION, + ), + ); + + $site_secrets = wp_list_secrets(); + $fields['site_secret_count'] = array( + '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' ), + 'value' => is_wp_error( $network_secrets ) ? __( 'Unavailable' ) : (string) count( $network_secrets ), + ); + } + + $info['secrets-api'] = array( + 'label' => __( 'Secrets API' ), + 'fields' => $fields, + ); + + return $info; +} +add_filter( 'debug_information', 'wp_secrets_site_health_debug_info' ); diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 461ab78a219e5..cb2402ed47b70 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -892,6 +892,10 @@ function upgrade_all() { upgrade_700(); } + if ( $wp_current_db_version < 61900 ) { + upgrade_720(); + } + maybe_disable_link_manager(); maybe_disable_automattic_widgets(); @@ -2502,6 +2506,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(): void { + 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 a1193dc3363d3..70eb1756f4488 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 d827afa5ba387..f736afb5b6f07 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -824,6 +824,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(): void { + 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/secrets.php b/src/wp-includes/secrets.php new file mode 100644 index 0000000000000..99d87ed0d7413 --- /dev/null +++ b/src/wp-includes/secrets.php @@ -0,0 +1,805 @@ + 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.' ), + 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.' ) + ); + } + + $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.' ) + ); + } + + /* + * 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.' ), + $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.' ) + ); + } + + 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.' ) + ); + } + + 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(): WP_Secrets_Store { + static $store = null; + + if ( $store instanceof WP_Secrets_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(): WP_Secrets_Key_Manager { + static $key_manager = null; + + if ( $key_manager instanceof WP_Secrets_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(): bool { + 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( string $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( string $option, string $name ) { + if ( '' === $option ) { + return new WP_Error( + WP_SECRETS_ERROR_INVALID_VALUE, + __( 'The option name must not be empty.' ) + ); + } + + $value = get_option( $option, null ); + + if ( null === $value ) { + return new WP_Error( + WP_SECRETS_ERROR_INVALID_VALUE, + __( '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.' ) + ); + } + + 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 + * + * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version + */ +function wp_get_secret( string $name, string $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( string $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( string $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'. + * + * @phpstan-return list|WP_Error + */ +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 ); +} + +/** + * 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( string $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 + * + * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version + */ +function wp_get_network_secret( string $name, string $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( string $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( string $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 + * + * @phpstan-return list|WP_Error + */ +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 ); +} + +/** + * 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(): WP_Secrets_Provider { + static $provider = null; + + if ( $provider instanceof WP_Secrets_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(): bool { + 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(): string { + 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( 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 ); +} + +/** + * 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 + * + * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version + */ +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 + * 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.' ), + '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.' ) + ); + } + + 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( string $name, bool $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( string $name, bool $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 + * + * @phpstan-return list|WP_Error + */ +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-version.php b/src/wp-includes/secrets/class-wp-secret-version.php new file mode 100644 index 0000000000000..fbbeca759a912 --- /dev/null +++ b/src/wp-includes/secrets/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 $name or $fingerprint is empty, or if $value + * is not a string. $value may be empty. + */ + 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 ( '' === $fingerprint ) { + throw new InvalidArgumentException( 'WP_Secret requires a non-empty 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 $name or $fingerprint fail the constructor's + * checks. + * + * @return WP_Secret + */ + public static function withheld( string $name, string $fingerprint, WP_Error $reason ): WP_Secret { + $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(): string { + return $this->fingerprint; + } + + /** + * Returns the secret's namespaced name. + * + * @since 7.2.0 + * + * @return string + */ + public function get_name(): string { + return $this->name; + } + + /** + * Builds the masked placeholder used everywhere a plaintext would otherwise appear. + * + * @since 7.2.0 + * + * @return string + * + * @phpstan-return non-falsy-string + */ + private function mask(): string { + 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 + * + * @phpstan-return non-falsy-string + */ + public function __toString(): string { + 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 + * + * @phpstan-return non-falsy-string + */ + public function jsonSerialize(): string { + 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( array $data ): void { + 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/secrets/class-wp-secrets-broken-keyring.php b/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php new file mode 100644 index 0000000000000..85b51f9e05e67 --- /dev/null +++ b/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php @@ -0,0 +1,73 @@ +error(); + } + + /** + * Always fails closed. + * + * @since 7.2.0 + * + * @param string $wrapped Ignored. + * @return WP_Error + */ + public function unwrap( string $wrapped ): WP_Error { + return $this->error(); + } + + /** + * Describes the broken state, for Site Health. + * + * @since 7.2.0 + * + * @return string + */ + public function get_key_source(): string { + return __( 'broken secrets.php drop-in' ); + } + + /** + * Builds the error every method returns. + * + * @since 7.2.0 + * + * @return WP_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 new file mode 100644 index 0000000000000..8c429c55e0705 --- /dev/null +++ b/src/wp-includes/secrets/class-wp-secrets-broken-provider.php @@ -0,0 +1,149 @@ +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( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ): WP_Error { + 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( string $name, bool $network = false ): WP_Error { + 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( string $name, bool $network = false ): WP_Error { + 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( string $name_prefix = '', bool $network = false ): WP_Error { + return $this->error(); + } + + /** + * Says plainly that the drop-in is the problem. + * + * @since 7.2.0 + * + * @return string + */ + public function get_label(): string { + return __( 'Unavailable: the secrets.php drop-in did not load correctly' ); + } + + /** + * Reports the provider boundary. + * + * Whatever was meant to protect these secrets is not WordPress, and is not + * working. + * + * @since 7.2.0 + * + * @return string + * + * @phpstan-return self::BOUNDARY_* + */ + public function get_protection_boundary(): string { + return self::BOUNDARY_PROVIDER; + } + + /** + * Nothing is writable while the drop-in is broken. + * + * @since 7.2.0 + * + * @return bool + */ + 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 new file mode 100644 index 0000000000000..31e64d8e5c7fc --- /dev/null +++ b/src/wp-includes/secrets/class-wp-secrets-broken-store.php @@ -0,0 +1,92 @@ +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( string $name, array $record, bool $network = false ): WP_Error { + return $this->error(); + } + + /** + * Always fails closed. + * + * @since 7.2.0 + * + * @param string $name Ignored. + * @param bool $network Ignored. + * @return WP_Error + */ + public function delete( string $name, bool $network = false ): WP_Error { + return $this->error(); + } + + /** + * Always fails closed. + * + * @since 7.2.0 + * + * @param bool $network Ignored. + * @return WP_Error + */ + public function list_names( bool $network = false ): WP_Error { + return $this->error(); + } + + + /** + * Builds the error every method returns. + * + * @since 7.2.0 + * + * @return WP_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 new file mode 100644 index 0000000000000..4dc631597ca3b --- /dev/null +++ b/src/wp-includes/secrets/class-wp-secrets-cipher.php @@ -0,0 +1,409 @@ +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.' ) + ); + } + + if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' ) ) { + return new WP_Error( + WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE, + __( 'No libsodium implementation is available.' ) + ); + } + + $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. + * + * @phpstan-param 'site'|'network' $scope + * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot + */ + 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 ) ) { + 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.' ) + ); + } + + $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 ( ! is_string( $data_key ) ) { + return new WP_Error( + WP_SECRETS_ERROR_DECRYPTION_FAILED, + __( "The secret's data key could not be decrypted." ) + ); + } + + $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 ( ! is_string( $plaintext ) ) { + return new WP_Error( + WP_SECRETS_ERROR_DECRYPTION_FAILED, + __( 'The secret value could not be decrypted.' ) + ); + } + + 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( string $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.' ) + ); + } + + if ( ! is_string( $plaintext ) ) { + return new WP_Error( + WP_SECRETS_ERROR_INVALID_VALUE, + __( '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.' ) + ); + } + + $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 + * + * @phpstan-param 'site'|'network' $scope + * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $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 ); + } + + /** + * 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 + * + * @phpstan-param 'site'|'network' $scope + * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $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 ( $site_id < 0 ) { + 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.' ) ); + } + + if ( ! $this->is_valid_master_key( $master_key ) ) { + return new WP_Error( + WP_SECRETS_ERROR_KEY_UNAVAILABLE, + __( 'A 32-byte master key is required.' ) + ); + } + + $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( 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 ); + } + + /** + * Whether a candidate value is usable as a 32-byte master key. + * + * @since 7.2.0 + * + * @param string $master_key Candidate master key. + * @return bool + */ + private function is_valid_master_key( string $master_key ): bool { + return 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.' ) + ); + } + + $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.' ), + $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.' ), + $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.' ) + ); + } + + return $decoded; + } +} 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 new file mode 100644 index 0000000000000..7fbd208818ea6 --- /dev/null +++ b/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php @@ -0,0 +1,311 @@ +use_previous_key = $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( string $key_material ) { + if ( '' === $key_material ) { + return new WP_Error( + WP_SECRETS_ERROR_INVALID_VALUE, + __( 'Key material to wrap must not be empty.' ) + ); + } + + if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' ) ) { + return new WP_Error( + WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE, + __( 'No libsodium implementation is available.' ) + ); + } + + $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( string $wrapped ) { + if ( '' === $wrapped ) { + return new WP_Error( + WP_SECRETS_ERROR_KEY_UNAVAILABLE, + __( '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.' ) + ); + } + + $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.' ) + ); + } + + $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 ( ! 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.' ) + ); + } + + return $key_material; + } + + /** + * Describes the active key source, for Site Health. + * + * @since 7.2.0 + * + * @return string + */ + 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)'; + } + + 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.' ), + $constant_name + ) + ); + } + + $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 ); + } + + if ( $this->use_previous_key ) { + return new WP_Error( + WP_SECRETS_ERROR_KEY_UNAVAILABLE, + __( 'WP_SECRETS_KEY_PREVIOUS is not defined.' ) + ); + } + + $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).' ) + ); + } + + 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 + * + * @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 ): bool { + 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 mixed $value Candidate value. + * @return bool + */ + private function is_canonical_base64_32( $value ): bool { + 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 ): ?string { + if ( ! is_string( $value ) ) { + return null; + } + + $decoded = base64_decode( $value, true ); + + if ( false === $decoded || SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES !== strlen( $decoded ) ) { + return null; + } + + 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 new file mode 100644 index 0000000000000..1cd92a3837023 --- /dev/null +++ b/src/wp-includes/secrets/class-wp-secrets-key-manager.php @@ -0,0 +1,382 @@ +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(): WP_Secrets_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. + * + * @phpstan-param 'site'|'network' $scope + */ + 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".' ); + + _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 ( $subkey_id < 1 ) { + $message = __( 'The site id must be a positive integer.' ); + + _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.' ) + ); + } + + $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.' ) + ); + } + + if ( null !== $this->cached_root_key && $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.' ) + ); + } + + 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 ); + } + + 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.' ) + ); + } + + $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 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 ); + + 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.' ) + ); + } + + $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/secrets/class-wp-secrets-libsodium-provider.php b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php new file mode 100644 index 0000000000000..d7dc205cf90a2 --- /dev/null +++ b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php @@ -0,0 +1,688 @@ +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(): string { + $keyring = $this->key_manager->get_keyring(); + + return sprintf( + /* translators: %s: Human-readable description of the key source. */ + __( 'WordPress (libsodium), key source: %s' ), + $keyring->get_key_source() + ); + } + + /** + * Protection happens inside WordPress for this provider. + * + * @since 7.2.0 + * + * @return string + * + * @phpstan-return self::BOUNDARY_* + */ + public function get_protection_boundary(): string { + 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(): bool { + 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.' ) + ); + } + + 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.' ), + 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.' ) + ); + } + + 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( WP_Secrets_Store $store, string $name, bool $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 ): string { + 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 ): string { + if ( ! is_array( $slot ) || ! isset( $slot['fingerprint'] ) || ! is_string( $slot['fingerprint'] ) ) { + return ''; + } + + return $slot['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. + * + * @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( 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 ) ) { + 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'] ) && is_int( $current_slot['created'] ) ? $current_slot['created'] : time(); + $demoted['needs_rotation'] = ! empty( $current_slot['needs_rotation'] ); + + 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( 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 ) ) { + return $name_check; + } + + if ( ! is_string( $value ) ) { + return new WP_Error( + WP_SECRETS_ERROR_INVALID_VALUE, + __( 'Secret values must be strings.' ) + ); + } + + $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. + * @param bool $network Whether the secret is network-scope. A site-scope + * secret belongs to the current site. + */ + 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'], + $network + ); + + 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 + * + * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version + */ + public function get( string $name, string $version, bool $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( string $name, bool $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 wp-includes/secrets/class-wp-secrets-libsodium-provider.php */ + do_action( + 'wp_secret_changed', + $name, + 'deleted', + get_current_user_id(), + time(), + $this->stored_fingerprint( $existing ), + '', + $network + ); + } + + 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( string $name, bool $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 = $this->slot_fingerprint( $record['previous'] ); + + unset( $record['previous'] ); + + $result = $store->set( $name, $record, $network ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + /** This action is documented in wp-includes/secrets/class-wp-secrets-libsodium-provider.php */ + do_action( + 'wp_secret_changed', + $name, + 'retired', + get_current_user_id(), + time(), + $retired_fingerprint, + '', + $network + ); + + 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. + * + * @phpstan-return list|WP_Error + */ + public function list_secrets( string $name_prefix = '', bool $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 ) && is_array( $record['current'] ) ) { + $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/secrets/class-wp-secrets-option-store.php b/src/wp-includes/secrets/class-wp-secrets-option-store.php new file mode 100644 index 0000000000000..7ffc1496528d6 --- /dev/null +++ b/src/wp-includes/secrets/class-wp-secrets-option-store.php @@ -0,0 +1,207 @@ +|null|WP_Error + */ + 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 ); + + 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.' ) + ); + } + + 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( string $name, array $record, bool $network = false ) { + $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.' ) + ); + } + + /** + * 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( string $name, bool $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.' ) + ); + } + + /** + * Lists the names of every secret in this store, for this scope. + * + * @since 7.2.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param bool $network Whether to list network-scope secrets. + * @return string[]|WP_Error + */ + public function list_names( bool $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.' ) + ); + } + + $names = array(); + + foreach ( $option_names as $option_name ) { + if ( ! is_string( $option_name ) ) { + continue; + } + + $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( 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 new file mode 100644 index 0000000000000..d378062908a9e --- /dev/null +++ b/src/wp-includes/secrets/interface-wp-secrets-keyring.php @@ -0,0 +1,58 @@ +|WP_Error + */ + public function list_secrets( string $name_prefix = '', bool $network = false ); + + /** + * A short human-readable name for this provider, for Site Health. + * + * Describes the protection, not the vendor's marketing: "AWS KMS + * (alias/wp-secrets)" tells an operator something, "SuperSecure Pro" does not. + * Never key material, never a credential, never a value. + * + * @since 7.2.0 + * + * @return string + */ + public function get_label(): string; + + /** + * Where secrets this provider holds are actually protected. + * + * One of the BOUNDARY_* constants. This is what lets Site Health and a future + * admin screen tell an operator whether their credentials are protected by + * WordPress's own libsodium envelope or by something outside it, a question + * they currently have no way to answer, and the one hosts asked to be able to + * answer honestly. + * + * @since 7.2.0 + * + * @return string + * + * @phpstan-return self::BOUNDARY_* + */ + public function get_protection_boundary(): string; + + /** + * Whether wp_set_secret() can succeed against this provider. + * + * False for a provider whose credentials are managed by host tooling or a + * control panel. Declared rather than discovered so a settings screen can + * disable its save control before an operator types a credential into a field + * that will only reject it. + * + * @since 7.2.0 + * + * @return bool + */ + 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 new file mode 100644 index 0000000000000..81759a483bec4 --- /dev/null +++ b/src/wp-includes/secrets/interface-wp-secrets-store.php @@ -0,0 +1,73 @@ +|null|WP_Error The record array if it exists. Null if it does not. + * WP_Error if it could not be determined which. + */ + public function get( string $name, bool $network = false ); + + /** + * 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 bool|WP_Error True on success. WP_Error on failure, including when the + * store does not accept writes. + */ + public function set( string $name, array $record, bool $network = false ); + + /** + * 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 bool|WP_Error True on success (including if it did not exist). + * WP_Error on failure. + */ + public function delete( string $name, bool $network = false ); + + /** + * Lists the names of every secret in this store, for this scope. Never a value. + * + * @since 7.2.0 + * + * @param bool $network Whether to list network-scope secrets. + * @return string[]|WP_Error Array of secret names on success. WP_Error on failure. + */ + public function list_names( bool $network = false ); +} 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 07756eb0480c2..c138458349aa3 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -311,6 +311,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 . '/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'; @@ -508,6 +522,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; diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 863d2b684d0ac..aa06cd9da0f81 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -132,6 +132,8 @@ parameters: - HEADER_IMAGE_HEIGHT - HEADER_IMAGE_WIDTH - HEADER_TEXTCOLOR + - LOGGED_IN_KEY + - LOGGED_IN_SALT - MEDIA_TRASH - MULTISITE - NO_HEADER_TEXT 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..159f0b108ec5d --- /dev/null +++ b/tests/phpunit/includes/secrets/class-mock-keyring.php @@ -0,0 +1,128 @@ + + */ + private $wrap_calls = 0; + + /** + * Number of times unwrap() has been called. + * + * @var int + * + * @phpstan-var int<0, max> + */ + private $unwrap_calls = 0; + + public function wrap( string $key_material ) { + ++$this->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( 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 ( 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(): string { + return 'mock keyring'; + } + + /** + * @param bool $fail Whether wrap() should return WP_Error. + * + * @return $this + */ + public function configure_fail_wrap( bool $fail = true ): self { + $this->fail_wrap = $fail; + + return $this; + } + + /** + * @param bool $fail Whether unwrap() should return WP_Error. + * + * @return $this + */ + public function configure_fail_unwrap( bool $fail = true ): self { + $this->fail_unwrap = $fail; + + return $this; + } + + /** + * @return int Number of times wrap() has been called. + * + * @phpstan-return int<0, max> + */ + 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(): 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 new file mode 100644 index 0000000000000..bef4fcabce7b8 --- /dev/null +++ b/tests/phpunit/includes/secrets/class-mock-store.php @@ -0,0 +1,116 @@ +> + */ + 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( string $name, bool $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( string $name, array $record, bool $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( string $name, bool $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( bool $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( string $operation, bool $fail = true ): self { + $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(): array { + 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( string $name, bool $network ): string { + 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..e893a9a127b93 --- /dev/null +++ b/tests/phpunit/includes/secrets/class-wp-secrets-keyring-conformance.php @@ -0,0 +1,116 @@ +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(): void { + $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(): void { + $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(): 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 ) ); + } + + /** + * 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(): 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 ); + + $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(): void { + $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..1a1806237242d --- /dev/null +++ b/tests/phpunit/includes/secrets/class-wp-secrets-provider-conformance.php @@ -0,0 +1,428 @@ +is_writable() ) { + $this->markTestSkipped( 'Provider declares itself read-only; write-path conformance does not apply.' ); + } + } + + /** + * 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 { + $label = $this->provider()->get_label(); + + $this->assertIsString( $label ); + $this->assertNotSame( '', trim( $label ) ); + } + + 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 ), + 'get_protection_boundary() must return one of the BOUNDARY_* constants.' + ); + } + + public function test_is_writable_returns_a_bool(): void { + $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(): void { + $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(): void { + $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(): void { + $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(): void { + $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(): void { + $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(): void { + $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(): void { + $provider = $this->provider(); + $this->require_writable( $provider ); + + $name = $this->conformance_name(); + $provider->set( $name, 'stable-value' ); + + $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(): void { + $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 ) ); + } + + // – 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 { + $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 ); + + $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', + $dump, + 'list_secrets() must never expose a plaintext.' + ); + } + + public function test_listing_includes_a_stored_secret(): void { + $provider = $this->provider(); + $this->require_writable( $provider ); + + $name = $this->conformance_name(); + $provider->set( $name, 'value' ); + + $entries = $provider->list_secrets(); + + $this->assertNotWPError( $entries ); + $this->assertContains( $name, wp_list_pluck( $entries, 'name' ) ); + } + + /** + * 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(): void { + $provider = $this->provider(); + $this->require_writable( $provider ); + + $provider->set( 'conformance-a/one', 'value' ); + $provider->set( 'conformance-b/two', 'value' ); + + $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 new file mode 100644 index 0000000000000..504d6dde591f1 --- /dev/null +++ b/tests/phpunit/includes/secrets/trait-wp-secrets-assertions.php @@ -0,0 +1,85 @@ +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. + * + * @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 ); + + $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 = '' ): 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. + $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..77b2875d79149 --- /dev/null +++ b/tests/phpunit/tests/secrets/brokenDropinFallbacks.php @@ -0,0 +1,78 @@ +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(): 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(): void { + $result = ( new WP_Secrets_Broken_Store() )->set( 'myplugin/api-key', array() ); + + $this->assertWPError( $result ); + } + + 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(): void { + $result = ( new WP_Secrets_Broken_Store() )->list_names(); + + $this->assertWPError( $result ); + } + + + 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(): 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(): void { + $result = ( new WP_Secrets_Broken_Keyring() )->unwrap( 'wrapped' ); + + $this->assertWPError( $result ); + } + + 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(): void { + $this->assertFalse( wp_using_secrets_dropin() ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + 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. + $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..de6cab23ae869 --- /dev/null +++ b/tests/phpunit/tests/secrets/capabilities.php @@ -0,0 +1,133 @@ +add_cap( WP_SECRETS_CAP_MANAGE ); + } + + parent::tear_down(); + } + + /** + * 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_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_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( $this->role( $role )->has_cap( WP_SECRETS_CAP_MANAGE ), $role ); + } + } + + 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( $this->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 ): void { + global $wp_current_db_version; + + $saved = $wp_current_db_version; + $wp_current_db_version = $db_version; + + $this->role( 'administrator' )->remove_cap( WP_SECRETS_CAP_MANAGE ); + + upgrade_720(); + + $wp_current_db_version = $saved; + + $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 ), + '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(): 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(): void { + $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(): void { + $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(): void { + $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( $expected, constant( $constant ) ); + } + + /** + * @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, + 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(): 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 new file mode 100644 index 0000000000000..565b6fc7fadd9 --- /dev/null +++ b/tests/phpunit/tests/secrets/extensionPoints.php @@ -0,0 +1,203 @@ +assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) ); + + $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(): void { + $GLOBALS['wp_secrets_keyring'] = new Mock_Keyring(); + + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) ); + + $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(): void { + $GLOBALS['wp_secrets_store'] = new Mock_Store(); + $GLOBALS['wp_secrets_keyring'] = new Mock_Keyring(); + + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) ); + + $secret = wp_get_secret( 'myplugin/api-key' ); + + $this->assertInstanceOf( WP_Secret::class, $secret ); + $this->assertSame( 'value', $secret->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(): 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 ) ) ); + + $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(): 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 ) ) ); + + $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' ) ); + + $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(): void { + $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(): void { + $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(): void { + $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(): void { + $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(): void { + $this->assertNull( wp_get_network_secret( 'myplugin/never-set' ) ); + } + + 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' ); + + $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(): 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(): 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' ); + + $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(): void { + 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(): void { + 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 ) ); + } + + /** + * 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' ); + + $entries = wp_list_network_secrets(); + + $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(): void { + $this->setExpectedIncorrectUsage( '_wp_secrets_get' ); + + $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() ); + } + + public function test_set_rejects_an_invalid_name(): void { + $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(): void { + 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(): void { + 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..2f4e19006f6ce --- /dev/null +++ b/tests/phpunit/tests/secrets/provider.php @@ -0,0 +1,271 @@ + + */ + public function list_secrets( string $name_prefix = '', bool $network = false ) { + return array(); + } + + /** + * @return string + */ + public function get_label(): string { + return 'Example Platform (KMS)'; + } + + /** + * @return string + */ + public function get_protection_boundary(): string { + return self::BOUNDARY_PROVIDER; + } + + /** + * @return bool + */ + public function is_writable(): bool { + return false; + } + }; + } + + // – the shipped provider ------------------------------------------------- + + 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(): void { + $this->assertSame( + WP_Secrets_Provider::BOUNDARY_WORDPRESS, + _wp_secrets_get_provider()->get_protection_boundary() + ); + } + + public function test_the_default_provider_accepts_writes(): void { + $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(): void { + $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(): void { + $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(): void { + $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(): void { + $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(): void { + $GLOBALS['wp_secrets_dropin_broken'] = true; + + $provider = _wp_secrets_get_provider(); + + $this->assertNotInstanceOf( 'WP_Secrets_Libsodium_Provider', $provider ); + $this->assertInstanceOf( 'WP_Secrets_Broken_Provider', $provider ); + $this->assertFalse( $provider->is_writable() ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + 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' ) ); + $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(): void { + $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(): void { + $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(): void { + $GLOBALS['wp_secrets_provider'] = new stdClass(); + + $result = wp_get_secret( 'myplugin/api-key' ); + + $this->assertNotNull( $result ); + $this->assertWPError( $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(): 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/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 @@ + + */ + public function list_secrets( string $name_prefix = '', bool $network = false ) { + return array(); + } + + /** + * @return string + */ + public function get_label(): string { + return 'Example Platform (control panel)'; + } + + /** + * @return string + */ + public function get_protection_boundary(): string { + return self::BOUNDARY_PROVIDER; + } + + /** + * @return bool + */ + public function is_writable(): bool { + return false; + } + }; + } +} diff --git a/tests/phpunit/tests/secrets/siteHealth.php b/tests/phpunit/tests/secrets/siteHealth.php new file mode 100644 index 0000000000000..d0eee49ceb421 --- /dev/null +++ b/tests/phpunit/tests/secrets/siteHealth.php @@ -0,0 +1,240 @@ +> $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 ); + $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->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(): 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(): 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.' ); + } + + $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(): void { + 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(): void { + 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(): void { + $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(): void { + 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(): 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(): void { + wp_set_secret( 'myplugin/api-key', 'value' ); + $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['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(): void { + wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ); + + $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 ); + + $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(): void { + 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(): void { + 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(): void { + wp_set_secret( 'myplugin/api-key', 'value' ); + + $info = wp_secrets_site_health_debug_info( array() ); + + $this->assertArrayHasKey( 'secrets-api', $info ); + $fields = $this->debug_fields( $info ); + + $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(): void { + $info = wp_secrets_site_health_debug_info( array() ); + + $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(): 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(): void { + $info = wp_secrets_site_health_debug_info( array() ); + + $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(): void { + 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', $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', $this->debug_fields( $info ) ); + } +} diff --git a/tests/phpunit/tests/secrets/threeStateContract.php b/tests/phpunit/tests/secrets/threeStateContract.php new file mode 100644 index 0000000000000..acf2666ecb54f --- /dev/null +++ b/tests/phpunit/tests/secrets/threeStateContract.php @@ -0,0 +1,203 @@ +assertNull( $result ); + } + + 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 ); + + $this->assertNull( $result ); + } + + 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' ); + + $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(): void { + 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(): void { + 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(): void { + 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(): 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.' ); + } + + $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(): void { + 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(): 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 ); + + $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(): void { + wp_set_secret( 'myplugin/api-key', 'value' ); + $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['current']['fingerprint'] = 'deadbeefdeadbeefdeadbeefdeadbeef'; + update_option( '_wp_secret_myplugin/api-key', $record, false ); + + $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(): void { + 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..274853b8699e0 --- /dev/null +++ b/tests/phpunit/tests/secrets/versionSlots.php @@ -0,0 +1,227 @@ + 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 ) ); + } + + /** + * The headline case: PREVIOUS must actually decrypt back to the 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' ); + + $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(): 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' ); + + $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(): void { + wp_set_secret( 'myplugin/api-key', 'first-value' ); + $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' ); + + $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(): void { + wp_set_secret( 'myplugin/api-key', 'first-value' ); + $original_created = $this->stored_slot( WP_Secret_Version::CURRENT )['created']; + + wp_set_secret( 'myplugin/api-key', 'second-value' ); + + $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(): 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' ); + $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( $this->stored_slot( WP_Secret_Version::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(): void { + wp_set_secret( 'myplugin/api-key', 'first-value' ); + $original_ciphertext = $this->stored_slot( WP_Secret_Version::CURRENT )['ct']; + + wp_set_secret( 'myplugin/api-key', 'second-value' ); + + $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. + $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(): void { + 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(): 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'] ); + $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' ) ); + $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(): 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 ) ); + $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(): 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(): void { + $this->assertTrue( wp_retire_secret_version( 'myplugin/never-set' ) ); + } + + 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(): void { + wp_set_secret( 'myplugin/api-key', 'first-value' ); + $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; + add_action( + 'wp_secret_changed', + function ( ...$args ) use ( &$captured ) { + $captured = $args; + }, + 10, + 7 + ); + + wp_retire_secret_version( 'myplugin/api-key' ); + + $this->assertIsArray( $captured ); + $this->assertCount( 7, $captured ); + + 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(): void { + 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..83b9bb3fc9f13 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpDeleteSecret.php @@ -0,0 +1,89 @@ +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(): void { + $this->assertTrue( wp_delete_secret( 'myplugin/never-set' ) ); + } + + 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(): void { + wp_set_secret( 'myplugin/api-key', 'value' ); + $secret = wp_get_secret( 'myplugin/api-key' ); + + $this->assertInstanceOf( WP_Secret::class, $secret ); + + $fingerprint = $secret->fingerprint(); + + $captured = null; + add_action( + 'wp_secret_changed', + function ( ...$args ) use ( &$captured ) { + $captured = $args; + }, + 10, + 7 + ); + + wp_delete_secret( 'myplugin/api-key' ); + + $this->assertIsArray( $captured ); + + 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(): void { + $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(): 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(): void { + 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..a6051be7f9524 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpImportOptionAsSecret.php @@ -0,0 +1,117 @@ +assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ) ); + + $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(): void { + 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(): 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(): 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(): 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(): void { + 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(): void { + 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(): void { + update_option( 'my_plugin_api_key', 'value' ); + + $captured = null; + add_action( + 'wp_secret_changed', + function ( ...$args ) use ( &$captured ) { + $captured = $args; + }, + 10, + 7 + ); + + 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 ); + $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(): 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' ); + + $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 new file mode 100644 index 0000000000000..a088eb3156a0c --- /dev/null +++ b/tests/phpunit/tests/secrets/wpListSecrets.php @@ -0,0 +1,137 @@ +assertSame( array(), wp_list_secrets() ); + } + + 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' ), + 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(): 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(): void { + wp_set_secret( 'myplugin/api-key', 'value' ); + + $secret = wp_get_secret( 'myplugin/api-key' ); + $entries = wp_list_secrets(); + + $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(): void { + wp_set_secret( 'myplugin/api-key', 'first-value' ); + + $before = wp_list_secrets(); + + $this->assertIsArray( $before ); + $this->assertFalse( $before[0]['has_previous'] ); + + wp_set_secret( 'myplugin/api-key', 'second-value' ); + + $after = wp_list_secrets(); + + $this->assertIsArray( $after ); + $this->assertTrue( $after[0]['has_previous'] ); + } + + 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' ); + + $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 ); + + $entries = wp_list_secrets(); + + $this->assertIsArray( $entries ); + $this->assertTrue( $entries[0]['needs_rotation'] ); + } + + public function test_filters_by_namespace(): void { + wp_set_secret( 'pluginone/key', 'value' ); + wp_set_secret( 'plugintwo/key', 'value' ); + + $entries = wp_list_secrets( 'pluginone' ); + + $this->assertIsArray( $entries ); + $this->assertSame( array( 'pluginone/key' ), wp_list_pluck( $entries, 'name' ) ); + } + + public function test_empty_namespace_returns_everything(): void { + wp_set_secret( 'pluginone/key', 'value' ); + wp_set_secret( 'plugintwo/key', 'value' ); + + $entries = wp_list_secrets( '' ); + + $this->assertIsArray( $entries ); + $this->assertCount( 2, $entries ); + } + + 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' ) ); + } + + /** + * 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 { + 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(): void { + wp_set_secret( 'myplugin/site-only', 'value' ); + _wp_secrets_set( 'myplugin/network-only', 'value', true ); + + $entries = wp_list_secrets(); + + $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 new file mode 100644 index 0000000000000..2418044531fab --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecret.php @@ -0,0 +1,348 @@ +assertTrue( $reflection->isFinal() ); + } + + public function test_implements_json_serializable(): void { + $this->assertInstanceOf( JsonSerializable::class, $this->make_secret() ); + } + + 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(): void { + $secret = $this->make_secret(); + + $this->assertSame( self::FINGERPRINT, $secret->fingerprint() ); + } + + 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(): void { + $this->expectException( InvalidArgumentException::class ); + + 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(): void { + $this->expectException( InvalidArgumentException::class ); + + new WP_Secret( '', self::PLAINTEXT, self::FINGERPRINT ); + } + + public function test_constructor_rejects_an_empty_fingerprint(): void { + $this->expectException( InvalidArgumentException::class ); + + new WP_Secret( self::NAME, self::PLAINTEXT, '' ); + } + + /** + * 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 ): 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 ( WP_Secret $secret ) { + return (string) $secret; + }, + ), + 'string interpolation' => array( + function ( WP_Secret $secret ) { + return "{$secret}"; + }, + ), + 'json_encode' => array( + function ( WP_Secret $secret ) { + return json_encode( $secret ); + }, + ), + 'var_dump' => array( + function ( WP_Secret $secret ) { + ob_start(); + var_dump( $secret ); + + return ob_get_clean(); + }, + ), + 'print_r' => array( + function ( WP_Secret $secret ) { + return print_r( $secret, true ); + }, + ), + 'var_export' => array( + function ( WP_Secret $secret ) { + return var_export( $secret, true ); + }, + ), + 'error_log' => array( + function ( WP_Secret $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(): void { + $secret = $this->make_secret(); + + $this->assertSame( '[secret:' . self::NAME . ']', (string) $secret ); + } + + 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. + $json = json_encode( $secret ); + + $this->assertIsString( $json ); + $this->assertSame( '[secret:' . self::NAME . ']', json_decode( $json ) ); + } + + public function test_var_dump_yields_the_masked_placeholder(): void { + $secret = $this->make_secret(); + + ob_start(); + var_dump( $secret ); + $output = ob_get_clean(); + + $this->assertStringContainsString( '[secret:' . self::NAME . ']', $output ); + } + + public function test_serialize_throws(): void { + $secret = $this->make_secret(); + + $this->expectException( LogicException::class ); + + serialize( $secret ); + } + + public function test_clone_throws(): void { + $secret = $this->make_secret(); + + $this->expectException( LogicException::class ); + + $clone = 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 + * + * @param string $method Magic method name. + * @param array $args Arguments to invoke it with. + */ + 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 ) { + $reflection->setAccessible( true ); + } + + $this->expectException( LogicException::class ); + + $reflection->invokeArgs( $secret, $args ); + } + + /** + * @return array}> + */ + 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(): void { + $secret = $this->make_secret(); + $id = spl_object_id( $secret ); + + $vault_property = new ReflectionProperty( WP_Secret::class, 'vault' ); + if ( PHP_VERSION_ID < 80100 ) { + $vault_property->setAccessible( true ); + } + + $vault = $vault_property->getValue(); + + $this->assertIsArray( $vault ); + $this->assertArrayHasKey( $id, $vault ); + + unset( $secret ); + + $vault = $vault_property->getValue(); + + $this->assertIsArray( $vault ); + $this->assertArrayNotHasKey( $id, $vault ); + } + + /** + * 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(): void { + $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(): void { + $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(): void { + $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(): void { + $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(): void { + $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(): void { + $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 ); + } + + /** + * 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(): 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 new file mode 100644 index 0000000000000..c972472bb4c91 --- /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(): void { + $reflection = new ReflectionClass( WP_Secret_Version::class ); + + $this->assertTrue( $reflection->isFinal() ); + } + + 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(): 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 new file mode 100644 index 0000000000000..63b8eec0a0bc3 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecretsCipher.php @@ -0,0 +1,395 @@ + $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 ) { + $reflection->setAccessible( true ); + } + + 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(): void { + $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(): void { + $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(): void { + $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(): void { + $cipher = new WP_Secrets_Cipher(); + + $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() ); + } + + /** + * @dataProvider data_bad_master_keys + * + * @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' ); + + $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 ) ), + 'too long' => array( str_repeat( 'a', 33 ) ), + 'empty' => array( '' ), + ); + } + + 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' ); + + $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(): void { + $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' ); // @phpstan-ignore argument.type (Intentionally passing an invalid 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(): void { + $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(): void { + $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' ); // @phpstan-ignore argument.type (Intentionally passing an invalid 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 + * + * @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. + * + * @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(); + $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() ); + } + + /** + * @return array + */ + 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(): void { + $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 + * + * @param string $field Record field to corrupt. + */ + 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' ); + + $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 ); + + $result = $cipher->decrypt_value( $this->master_key(), 'site', 1, self::NAME, WP_Secret_Version::CURRENT, $record ); + + $this->assertWPError( $result ); + } + + /** + * @return array + */ + 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 + * + * @param mixed $record Stored record the cipher must refuse. + */ + 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 ); + + $this->assertWPError( $result ); + $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' ), + '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(): 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(): 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' ); + + $this->assertIsArray( $record ); + + $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(): void { + $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(): void { + $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(): void { + $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(): void { + $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(): void { + $cipher = new WP_Secrets_Cipher(); + + $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() ); + } + + /** + * 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(): void { + $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(): void { + $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(): void { + $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..b19a9941ef049 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php @@ -0,0 +1,332 @@ + $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 ); + } + + return $reflection->invokeArgs( $instance, $args ); + } + + 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(): void { + $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_an_empty_string(): void { + $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(): 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_an_empty_string(): void { + $provider = new WP_Secrets_Config_Key_Provider(); + + $result = $provider->unwrap( '' ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + } + + public function test_unwrap_rejects_garbage_base64(): void { + $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(): void { + $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(): 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 ); + + $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(): 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.' ); + } + + $provider = new WP_Secrets_Config_Key_Provider(); + + $this->assertStringContainsString( 'LOGGED_IN_KEY', $provider->get_key_source() ); + } + + /** + * @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 ): void { + $provider = new WP_Secrets_Config_Key_Provider(); + + $this->assertSame( $expected, $this->invoke_private( $provider, 'is_canonical_base64_32', array( $value ) ) ); + } + + /** + * @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 ), + '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 + * + * @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 ): void { + $provider = new WP_Secrets_Config_Key_Provider(); + + $this->assertSame( $expected, $this->invoke_private( $provider, 'are_usable_salt_values', array( $key, $salt ) ) ); + } + + /** + * @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 ), + '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(): void { + $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(): 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 ) + ); + } + + /** + * 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(): void { + $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(): void { + 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(): void { + 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(): void { + 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(): void { + 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(): 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.' ); + } + + 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(): void { + $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(): void { + 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->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 new file mode 100644 index 0000000000000..a42f6078bd435 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecretsKeyManager.php @@ -0,0 +1,466 @@ +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(): 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(): void { + $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(): void { + $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(): 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(): void { + $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(): void { + $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(): void { + $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(): void { + $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(): void { + $manager = new WP_Secrets_Key_Manager(); + + $this->setExpectedIncorrectUsage( 'WP_Secrets_Key_Manager::get_master_key' ); + + $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() ); + } + + /** + * @dataProvider data_invalid_site_ids + * + * @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 ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_ARGUMENT, $result->get_error_code() ); + } + + /** + * @return array + */ + public function data_invalid_site_ids(): array { + return array( + 'zero' => array( 0 ), + 'negative' => array( -1 ), + ); + } + + /** + * 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(): void { + 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(): void { + 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(): void { + 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->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 ) ); + } + + /** + * 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(): void { + 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->assertInstanceOf( WP_Secret::class, $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(): void { + 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(): void { + $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(): 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( string $key_material ) { + return base64_encode( $key_material ); + } + + /** + * @param string $wrapped Wrapped value. + * @return WP_Error + */ + public function unwrap( string $wrapped ) { + return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, 'wrong key' ); + } + + /** + * @return string + */ + public function get_key_source(): string { + 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(): void { + 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(): 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 ); + + for ( $i = 0; $i < 5; $i++ ) { + $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() ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + 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 ) ); + + $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(): void { + $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(): void { + $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(): 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 ); + $while_failing = $manager->get_root_key(); + $this->assertWPError( $while_failing ); + + $mock->configure_fail_unwrap( false ); + $after_recovery = $manager->get_root_key(); + $this->assertSame( $root, $after_recovery ); + + $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(): void { + $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(): 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 ); + + $copy = $manager->get_root_key(); + $this->assertIsString( $copy ); + 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..350a3fc6cf7ff --- /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(): void { + $value = 42; + + wp_secrets_memzero( $value ); // @phpstan-ignore argument.type (Intentionally passing a non-string value.) + + $this->assertSame( 42, $value ); + } + + public function test_leaves_an_empty_string_untouched(): void { + $value = ''; + + wp_secrets_memzero( $value ); + + $this->assertSame( '', $value ); + } + + public function test_does_not_throw_when_the_value_is_shared(): void { + $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 ); // @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 new file mode 100644 index 0000000000000..2c612624a8798 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecretsOptionStore.php @@ -0,0 +1,169 @@ +} + */ + private function sample_record(): array { + return array( + 'v' => 1, + 'current' => array( + 'dk' => 'x', + 'dk_nonce' => 'x', + 'ct' => 'x', + 'nonce' => 'x', + 'fingerprint' => 'x', + 'created' => 12345, + ), + ); + } + + 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(): 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(): void { + $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(): void { + $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(): void { + $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_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(); + + $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(): void { + $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(): void { + $store = new WP_Secrets_Option_Store(); + + $this->assertTrue( $store->delete( 'myplugin/never-existed' ) ); + } + + /** + * @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(); + $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(): 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(): void { + update_option( 'completely_unrelated_option', 'value' ); + + $store = new WP_Secrets_Option_Store(); + $store->set( 'myplugin/one', $this->sample_record() ); + + $names = $store->list_names(); + + $this->assertIsArray( $names ); + $this->assertNotContains( 'completely_unrelated_option', $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 ); + + $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 ); + } + + /** + * '_' 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(): 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 ); + + $store = new WP_Secrets_Option_Store(); + $store->set( 'myplugin/real', $this->sample_record() ); + + $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 new file mode 100644 index 0000000000000..0e097a8ccd000 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSecretsValidateName.php @@ -0,0 +1,107 @@ +assertTrue( wp_secrets_validate_name( $name ) ); + } + + /** + * @return array + */ + public function data_valid_names(): array { + 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 + * + * @param string $name A name that must be rejected. + */ + public function test_rejects_invalid_names( $name ): void { + $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 + * + * @param string $name An unnamespaced name that must be accepted. + */ + 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 ) ); + } + + /** + * @return array + */ + public function data_unnamespaced_names(): array { + 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(): void { + $result = wp_secrets_validate_name( 'Not_A_Valid_Name' ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); + } + + /** + * @return array + */ + public function data_invalid_names(): array { + 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' ), + '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..a7943aee78067 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpSetSecretAndWpGetSecret.php @@ -0,0 +1,234 @@ +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(): void { + $this->assertNull( wp_get_secret( 'myplugin/never-set' ) ); + } + + public function test_get_defaults_to_the_current_version(): void { + wp_set_secret( 'myplugin/api-key', 'value' ); + + $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(): void { + 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(): void { + $this->setExpectedIncorrectUsage( '_wp_secrets_get' ); + + $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() ); + } + + 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(): 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(): 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(): void { + $this->assertTrue( wp_set_secret( 'myplugin/api-key', '' ) ); + + $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(): void { + update_option( '_wp_secret_myplugin/corrupt', 'not a record at all', false ); + + $this->assertTrue( wp_set_secret( 'myplugin/corrupt', 'a fresh value' ) ); + + $secret = wp_get_secret( 'myplugin/corrupt' ); + + $this->assertInstanceOf( WP_Secret::class, $secret ); + $this->assertSame( 'a fresh value', $secret->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(): void { + global $wpdb; + + $this->assertInstanceOf( wpdb::class, $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(): void { + wp_set_secret( 'myplugin/api-key', 'must-survive-memzero' ); + + $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(): 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(): void { + $captured = null; + add_action( + 'wp_secret_changed', + function ( ...$args ) use ( &$captured ) { + $captured = $args; + }, + 10, + 7 + ); + + wp_set_secret( 'myplugin/api-key', 'value' ); + + $this->assertNotNull( $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 { + wp_set_secret( 'myplugin/api-key', 'first-value' ); + $first = wp_get_secret( 'myplugin/api-key' ); + + $this->assertInstanceOf( WP_Secret::class, $first ); + $first_fingerprint = $first->fingerprint(); + + $captured = null; + add_action( + 'wp_secret_changed', + function ( ...$args ) use ( &$captured ) { + $captured = $args; + }, + 10, + 7 + ); + + wp_set_secret( 'myplugin/api-key', 'second-value' ); + + $this->assertIsArray( $captured ); + 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(): void { + $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(): void { + $captured_args = null; + add_action( + 'wp_secret_changed', + function ( ...$args ) use ( &$captured_args ) { + $captured_args = $args; + }, + 10, + 7 + ); + + 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 ); + } + } +} diff --git a/tests/phpunit/tests/user/capabilities.php b/tests/phpunit/tests/user/capabilities.php index 3386d292177e6..618827d85d020 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(),