From 872208a27782fda3c2af59327f34829fe03314a3 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 14 May 2026 14:27:09 -0600 Subject: [PATCH 1/7] Add the Key Encryption experiment along with a secrets bridge class that uses the Displace Secrets Manager (if it exists) to encrypt and decrypt AI Connector API Keys --- includes/Experiments/Experiments.php | 1 + .../Key_Encryption/Key_Encryption.php | 179 +++++++++ .../Key_Encryption/Secrets_Bridge.php | 365 ++++++++++++++++++ 3 files changed, 545 insertions(+) create mode 100644 includes/Experiments/Key_Encryption/Key_Encryption.php create mode 100644 includes/Experiments/Key_Encryption/Secrets_Bridge.php diff --git a/includes/Experiments/Experiments.php b/includes/Experiments/Experiments.php index 8aeaecb53..6ebbed818 100644 --- a/includes/Experiments/Experiments.php +++ b/includes/Experiments/Experiments.php @@ -38,6 +38,7 @@ final class Experiments { \WordPress\AI\Experiments\Summarization\Summarization::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, \WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation::class, + \WordPress\AI\Experiments\Key_Encryption\Key_Encryption::class, ); /** diff --git a/includes/Experiments/Key_Encryption/Key_Encryption.php b/includes/Experiments/Key_Encryption/Key_Encryption.php new file mode 100644 index 000000000..480ae06e6 --- /dev/null +++ b/includes/Experiments/Key_Encryption/Key_Encryption.php @@ -0,0 +1,179 @@ + __( 'Key Encryption', 'ai' ), + 'description' => __( 'Encrypts AI provider API keys at rest using the Displace Secrets Manager plugin. Keys are transparently decrypted on read and re-encrypted on write. Disabling the experiment or deactivating the plugin restores plaintext keys.', 'ai' ), + 'category' => Experiment_Category::ADMIN, + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public function register(): void { + self::get_bridge()->register_option_filters(); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public function register_settings(): void { + $option = 'wpai_feature_' . self::get_id() . '_enabled'; + + if ( false === has_action( "update_option_{$option}", array( self::class, 'handle_toggle_update' ) ) ) { + add_action( "update_option_{$option}", array( self::class, 'handle_toggle_update' ), 10, 2 ); + } + + if ( false !== has_action( "add_option_{$option}", array( self::class, 'handle_toggle_add' ) ) ) { + return; + } + + add_action( "add_option_{$option}", array( self::class, 'handle_toggle_add' ), 10, 2 ); + } + + /** + * Static handler for the toggle update action. + * + * @since x.x.x + * + * @param mixed $old_value Previous option value. + * @param mixed $new_value New option value. + */ + public static function handle_toggle_update( $old_value, $new_value ): void { + $was_enabled = self::coerce_bool( $old_value ); + $is_enabled = self::coerce_bool( $new_value ); + + if ( $was_enabled === $is_enabled ) { + return; + } + + if ( $is_enabled ) { + self::get_bridge()->encrypt_all(); + return; + } + + self::get_bridge()->decrypt_all(); + } + + /** + * Static handler for the toggle add action. + * + * @since x.x.x + * + * @param string $option Option name. + * @param mixed $new_value New option value. + */ + public static function handle_toggle_add( $option, $new_value ): void { + unset( $option ); + if ( ! self::coerce_bool( $new_value ) ) { + return; + } + + self::get_bridge()->encrypt_all(); + } + + /** + * Coerces a stored option value to a boolean. + * + * Settings stored via the REST API can arrive as + * `'1'`, `'0'`, `''`, `true`, `false`, etc. + * + * @since x.x.x + * + * @param mixed $value Raw option value. + * @return bool The coerced boolean value. + */ + private static function coerce_bool( $value ): bool { + if ( is_bool( $value ) ) { + return $value; + } + + if ( is_string( $value ) ) { + return '' !== $value && '0' !== $value && 'false' !== strtolower( $value ); + } + + if ( is_numeric( $value ) ) { + return 0 !== (int) $value; + } + + return (bool) $value; + } +} diff --git a/includes/Experiments/Key_Encryption/Secrets_Bridge.php b/includes/Experiments/Key_Encryption/Secrets_Bridge.php new file mode 100644 index 000000000..d96c6e037 --- /dev/null +++ b/includes/Experiments/Key_Encryption/Secrets_Bridge.php @@ -0,0 +1,365 @@ +get_connector_setting_names() as $setting_name ) { + $write_hook = "pre_update_option_{$setting_name}"; + $read_hook = "option_{$setting_name}"; + + if ( false === has_filter( $write_hook, array( $this, 'on_write' ) ) ) { + add_filter( $write_hook, array( $this, 'on_write' ), 10, 1 ); + } + + if ( false !== has_filter( $read_hook, array( $this, 'on_read' ) ) ) { + continue; + } + + add_filter( $read_hook, array( $this, 'on_read' ), 10, 2 ); + } + } + + /** + * Unregisters every transparent option filter previously installed. + * + * Called before `decrypt_all()` so the plaintext writes during + * reversal are not re-encrypted by the very filters we are tearing down. + * + * @since x.x.x + */ + public function unregister_option_filters(): void { + foreach ( $this->get_connector_setting_names() as $setting_name ) { + remove_filter( "pre_update_option_{$setting_name}", array( $this, 'on_write' ), 10 ); + remove_filter( "option_{$setting_name}", array( $this, 'on_read' ), 10 ); + } + } + + /** + * Encrypts every existing plaintext connector API key into the secrets store. + * + * Reads each `connectors_ai_*_api_key` option, stores it as a secret, and + * writes the wp_options row back to an empty string. Skips empty values. + * After completion, registers the read filter so subsequent reads in + * the same request return the decrypted value. + * + * @since x.x.x + * + * @return int Number of keys encrypted. + */ + public function encrypt_all(): int { + if ( ! $this->is_secrets_manager_available() ) { + return 0; + } + + $count = 0; + foreach ( $this->get_connector_setting_names() as $connector_id => $setting_name ) { + $plaintext = $this->read_raw_option( $setting_name ); + if ( '' === $plaintext ) { + continue; + } + + $stored = set_secret( $this->secret_key( $connector_id ), $plaintext ); + if ( ! $stored ) { + continue; + } + + update_option( $setting_name, '' ); + ++$count; + } + + // Flush the alloptions cache so subsequent get_option() calls in the same request don't + // serve stale plaintext from cache before our read filter is in place. + wp_cache_delete( 'alloptions', 'options' ); + + $this->register_option_filters(); + + return $count; + } + + /** + * Decrypts every secret back into plaintext wp_options storage and removes the secret. + * + * Used when the user opts out of the experiment or deactivates the + * plugin while the experiment is enabled, so the user is never locked out + * of their own credentials. + * + * @since x.x.x + * + * @return int Number of keys restored. + */ + public function decrypt_all(): int { + if ( ! $this->is_secrets_manager_available() ) { + return 0; + } + + // Tear down the transparent filters first so the plaintext writes below are not + // immediately re-encrypted by `on_write`. + $this->unregister_option_filters(); + + $count = 0; + foreach ( $this->get_connector_setting_names() as $connector_id => $setting_name ) { + $plaintext = get_secret( $this->secret_key( $connector_id ) ); + if ( null === $plaintext || '' === $plaintext ) { + continue; + } + + update_option( $setting_name, $plaintext ); + delete_secret( $this->secret_key( $connector_id ) ); + ++$count; + } + + wp_cache_delete( 'alloptions', 'options' ); + + return $count; + } + + /** + * Filter callback for `pre_update_option_{$setting_name}`. + * + * Stores the secret out-of-band and forces the wp_options row to remain empty. + * + * @since x.x.x + * + * @param mixed $value New value being written. + * @return string Always empty — the real value lives in the secrets store. + */ + public function on_write( $value ): string { + if ( ! is_string( $value ) || '' === $value ) { + $this->delete_secret_for_current_filter(); + return ''; + } + + if ( ! $this->is_secrets_manager_available() ) { + // Without the secrets manager we cannot encrypt, so fail safe by passing the value + // through unmodified rather than dropping the user's key on the floor. + return $value; + } + + $connector_id = $this->connector_id_for_current_filter(); + if ( null === $connector_id ) { + return $value; + } + + set_secret( $this->secret_key( $connector_id ), $value ); + return ''; + } + + /** + * Filter callback for `option_{$setting_name}`. + * + * Returns the decrypted secret if one is stored; otherwise passes + * through to the stored value (which may be a not-yet-migrated plaintext key). + * + * @since x.x.x + * + * @param mixed $value Stored option value. + * @param string $option Option name. + * @return mixed Decrypted value, or the original stored value. + */ + public function on_read( $value, string $option ) { + if ( $this->bypass_read_filter ) { + return $value; + } + + if ( ! $this->is_secrets_manager_available() ) { + return $value; + } + + $connector_id = $this->connector_id_from_setting_name( $option ); + if ( null === $connector_id ) { + return $value; + } + + $secret = get_secret( $this->secret_key( $connector_id ) ); + if ( null === $secret ) { + return $value; + } + + return $secret; + } + + /** + * Returns whether the displace-secrets-manager plugin is loaded. + * + * @since x.x.x + * + * @return bool Whether the displace-secrets-manager plugin is loaded. + */ + public function is_secrets_manager_available(): bool { + return function_exists( 'set_secret' ) + && function_exists( 'get_secret' ) + && function_exists( 'delete_secret' ); + } + + /** + * Returns a map of connector_id => setting_name for every connector that uses api_key auth. + * + * Includes inactive connectors so we can clean up keys stored by + * previously-active connectors. + * + * @since x.x.x + * + * @return array + */ + public function get_connector_setting_names(): array { + $map = array(); + + foreach ( get_ai_connectors( false ) as $connector_id => $data ) { + $auth = $data['authentication'] ?? array(); + + if ( ! is_array( $auth ) ) { + continue; + } + + if ( ( $auth['method'] ?? '' ) !== 'api_key' ) { + continue; + } + + $setting_name = $auth['setting_name'] ?? ''; + if ( ! is_string( $setting_name ) || '' === $setting_name ) { + continue; + } + + $map[ $connector_id ] = $setting_name; + } + + return $map; + } + + /** + * Reads a wp_option without triggering our read filter (returns the actual stored value). + * + * @since x.x.x + * + * @param string $option_name The wp_option name. + * @return string The raw option value. + */ + private function read_raw_option( string $option_name ): string { + $this->bypass_read_filter = true; + try { + $value = get_option( $option_name, '' ); + } finally { + $this->bypass_read_filter = false; + } + + return is_string( $value ) ? $value : ''; + } + + /** + * Builds the namespaced secret key for a given connector id. + * + * @since x.x.x + * + * @param string $connector_id The connector id. + * @return string The namespaced secret key. + */ + private function secret_key( string $connector_id ): string { + return self::SECRET_NAMESPACE . '/' . $connector_id . '_api_key'; + } + + /** + * Reverse-lookup: given the wp_option name from the current filter context, find the connector id. + * + * @since x.x.x + * + * @param string $setting_name The wp_option name. + * @return string|null The connector id, or null if not found. + */ + private function connector_id_from_setting_name( string $setting_name ): ?string { + foreach ( $this->get_connector_setting_names() as $connector_id => $candidate ) { + if ( $candidate === $setting_name ) { + return $connector_id; + } + } + return null; + } + + /** + * Resolves the connector id from the current `pre_update_option_{name}` filter. + * + * WordPress strips the prefix before invoking the callback, so we + * recover the option name from `current_filter()` and then map it + * to a connector id. + * + * @since x.x.x + * + * @return string|null The connector id, or null if not found. + */ + private function connector_id_for_current_filter(): ?string { + $filter = current_filter(); + if ( ! is_string( $filter ) || 0 !== strpos( $filter, 'pre_update_option_' ) ) { + return null; + } + + $setting_name = substr( $filter, strlen( 'pre_update_option_' ) ); + return $this->connector_id_from_setting_name( $setting_name ); + } + + /** + * Deletes the secret tied to the current write-filter context, if any. + * + * Called when an empty value is being written (treat as "clear the key"). + * + * @since x.x.x + */ + private function delete_secret_for_current_filter(): void { + if ( ! $this->is_secrets_manager_available() ) { + return; + } + + $connector_id = $this->connector_id_for_current_filter(); + if ( null === $connector_id ) { + return; + } + + delete_secret( $this->secret_key( $connector_id ) ); + } +} From 8eb272e99a45d5716d042bd0e8058162e0298a31 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 14 May 2026 14:28:36 -0600 Subject: [PATCH 2/7] Add a Deactivation class and add a routine that decrypts all keys if encryption was on --- includes/Admin/Deactivation.php | 43 +++++++++++++++++++++++++++++++++ includes/Main.php | 2 ++ 2 files changed, 45 insertions(+) create mode 100644 includes/Admin/Deactivation.php diff --git a/includes/Admin/Deactivation.php b/includes/Admin/Deactivation.php new file mode 100644 index 000000000..410e6b2bc --- /dev/null +++ b/includes/Admin/Deactivation.php @@ -0,0 +1,43 @@ +decrypt_all(); + } +} diff --git a/includes/Main.php b/includes/Main.php index da4ba6b72..9fa38ea3c 100644 --- a/includes/Main.php +++ b/includes/Main.php @@ -14,6 +14,7 @@ use WordPress\AI\Abilities\Utilities\Posts; use WordPress\AI\Admin\Activation; use WordPress\AI\Admin\Dashboard\Dashboard_Widgets; +use WordPress\AI\Admin\Deactivation; use WordPress\AI\Admin\Upgrades; use WordPress\AI\Experiments\Experiments; use WordPress\AI\Features\Loader; @@ -64,6 +65,7 @@ private function setup(): void { // Register activation and deactivation hooks. register_activation_hook( WPAI_PLUGIN_FILE, array( Activation::class, 'activation_callback' ) ); + register_deactivation_hook( WPAI_PLUGIN_FILE, array( Deactivation::class, 'deactivation_callback' ) ); } /** From 859db569f1dd6f36a0b8f4aa106d517cbc06e88e Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 14 May 2026 14:46:47 -0600 Subject: [PATCH 3/7] Add documentation --- docs/experiments/key-encryption.md | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/experiments/key-encryption.md diff --git a/docs/experiments/key-encryption.md b/docs/experiments/key-encryption.md new file mode 100644 index 000000000..c35d558f4 --- /dev/null +++ b/docs/experiments/key-encryption.md @@ -0,0 +1,37 @@ +# Key Encryption + +Opt-in experiment that encrypts AI connector API keys at rest. + +## Summary + +- Extends `Abstract_Feature`. +- While enabled, every `connectors_ai_*_api_key` option is transparently routed through the + [Displace Secrets Manager](https://github.com/ericmann/displace-secrets-manager) plugin's + `set_secret()` / `get_secret()` API, so the `wp_options` table never contains a plaintext key. +- Existing keys are encrypted on opt-in, restored on opt-out, and restored on plugin deactivation + — users cannot get locked out of their own credentials. + +## Requirements + +This experiment depends on the +[Displace Secrets Manager](https://github.com/ericmann/displace-secrets-manager) plugin being +installed and active. If the secrets manager plugin is missing, enabling this experiment has no +effect: writes pass through unchanged so user keys are never silently dropped. + +For meaningful at-rest security, also define `WP_SECRETS_KEY` in `wp-config.php`. Without it, +Displace Secrets Manager derives an encryption key from existing WordPress salts +(`LOGGED_IN_KEY . LOGGED_IN_SALT`) — usable for a proof-of-concept, but weaker than a dedicated +key. Generate one with `wp secret generate-key`. + +## How it works + +While enabled, the experiment registers two transparent option filters per connector: + +- `pre_update_option_{setting_name}` — encrypts the value via `set_secret()` and forces the + `wp_options` row to remain empty. +- `option_{setting_name}` — decrypts and returns the secret on read; passes through to the + stored value if no secret exists (handles partially-migrated state). + +All existing callers — `Connector_Key_Index`, REST dispatch, the AI client registry — keep +working because `get_option()` transparently returns the decrypted value through the read +filter. From 2e86517eb29dc1f5f458ddf1fa521355e5141cfb Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 14 May 2026 14:48:12 -0600 Subject: [PATCH 4/7] Add unit tests --- .../Key_Encryption/Key_EncryptionTest.php | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 tests/Integration/Includes/Experiments/Key_Encryption/Key_EncryptionTest.php diff --git a/tests/Integration/Includes/Experiments/Key_Encryption/Key_EncryptionTest.php b/tests/Integration/Includes/Experiments/Key_Encryption/Key_EncryptionTest.php new file mode 100644 index 000000000..badd2a454 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Key_Encryption/Key_EncryptionTest.php @@ -0,0 +1,267 @@ +register_test_connector(); + + // Defang the WP 7.0 connector sanitize/mask filters so we can write/read raw values. + remove_all_filters( 'sanitize_option_' . self::SETTING_NAME ); + remove_filter( 'option_' . self::SETTING_NAME, '_wp_connectors_mask_api_key' ); + + delete_option( self::SETTING_NAME ); + delete_option( self::TOGGLE ); + + update_option( 'wpai_features_enabled', true ); + + // The plugin's normal boot flow has already instantiated the experiment and wired its + // toggle hooks via Settings_Registration. We just need a reference for read-bypass + // helpers; the singleton bridge accessor returns the same bridge those hooks use. + $this->experiment = new Key_Encryption(); + $this->experiment->register_settings(); + } + + /** + * @since x.x.x + */ + public function tearDown(): void { + remove_all_actions( "update_option_{$this->toggle()}" ); + remove_all_actions( "add_option_{$this->toggle()}" ); + delete_option( 'wpai_features_enabled' ); + delete_option( self::TOGGLE ); + delete_option( self::SETTING_NAME ); + $GLOBALS['wpai_test_secret_store'] = array(); + parent::tearDown(); + } + + /** + * @since x.x.x + */ + private function toggle(): string { + return self::TOGGLE; + } + + /** + * @since x.x.x + */ + public function test_round_trip_when_enabled() { + update_option( self::TOGGLE, true ); + + update_option( self::SETTING_NAME, 'sk-secret-value' ); + + $this->assertSame( '', $this->raw_option( self::SETTING_NAME ) ); + $this->assertSame( 'sk-secret-value', get_option( self::SETTING_NAME ) ); + $this->assertSame( 'sk-secret-value', $GLOBALS['wpai_test_secret_store'][ self::SECRET_KEY ] ?? null ); + } + + /** + * @since x.x.x + */ + public function test_opt_in_encrypts_existing_plaintext_keys() { + update_option( self::SETTING_NAME, 'sk-plaintext' ); + $this->assertSame( 'sk-plaintext', $this->raw_option( self::SETTING_NAME ) ); + + update_option( self::TOGGLE, true ); + + $this->assertSame( '', $this->raw_option( self::SETTING_NAME ) ); + $this->assertSame( 'sk-plaintext', $GLOBALS['wpai_test_secret_store'][ self::SECRET_KEY ] ?? null ); + $this->assertSame( 'sk-plaintext', get_option( self::SETTING_NAME ) ); + } + + /** + * @since x.x.x + */ + public function test_opt_out_restores_plaintext() { + update_option( self::TOGGLE, true ); + update_option( self::SETTING_NAME, 'sk-restored' ); + + update_option( self::TOGGLE, false ); + + $this->assertSame( 'sk-restored', $this->raw_option( self::SETTING_NAME ) ); + $this->assertArrayNotHasKey( self::SECRET_KEY, $GLOBALS['wpai_test_secret_store'] ); + } + + /** + * @since x.x.x + */ + public function test_deactivation_restores_plaintext() { + update_option( self::TOGGLE, true ); + update_option( self::SETTING_NAME, 'sk-deactivate' ); + + Deactivation::deactivation_callback(); + + $this->assertSame( 'sk-deactivate', $this->raw_option( self::SETTING_NAME ) ); + $this->assertArrayNotHasKey( self::SECRET_KEY, $GLOBALS['wpai_test_secret_store'] ); + } + + /** + * @since x.x.x + */ + public function test_deactivation_with_experiment_disabled_is_noop() { + update_option( self::SETTING_NAME, 'sk-plaintext' ); + + Deactivation::deactivation_callback(); + + $this->assertSame( 'sk-plaintext', $this->raw_option( self::SETTING_NAME ) ); + } + + /** + * @since x.x.x + */ + public function test_write_with_empty_string_clears_secret() { + update_option( self::TOGGLE, true ); + update_option( self::SETTING_NAME, 'sk-temp' ); + $this->assertArrayHasKey( self::SECRET_KEY, $GLOBALS['wpai_test_secret_store'] ); + + update_option( self::SETTING_NAME, '' ); + + $this->assertArrayNotHasKey( self::SECRET_KEY, $GLOBALS['wpai_test_secret_store'] ); + $this->assertSame( '', $this->raw_option( self::SETTING_NAME ) ); + } + + /** + * @since x.x.x + */ + public function test_read_passthrough_when_no_secret_stored() { + // Experiment enabled but no migration ever ran for this key — the read filter should + // transparently fall through to the stored plaintext. + update_option( self::SETTING_NAME, 'sk-untouched' ); + update_option( self::TOGGLE, true ); + + // After opt-in, the value was migrated to the secret store. Now wipe just the secret + // store entry to simulate a key that exists only as plaintext (e.g., partial state). + unset( $GLOBALS['wpai_test_secret_store'][ self::SECRET_KEY ] ); + update_option( self::SETTING_NAME, '' ); // Re-clear the wp_options row to ensure clean state. + + $this->set_raw_option( self::SETTING_NAME, 'sk-fallback' ); + $this->assertSame( 'sk-fallback', get_option( self::SETTING_NAME ) ); + } + + /** + * Reads a wp_option directly without our read filter intercepting. + * + * @since x.x.x + */ + private function raw_option( string $option ): string { + $bridge = Key_Encryption::get_bridge(); + remove_filter( "option_{$option}", array( $bridge, 'on_read' ), 10 ); + $value = get_option( $option, '' ); + add_filter( "option_{$option}", array( $bridge, 'on_read' ), 10, 2 ); + return is_string( $value ) ? $value : ''; + } + + /** + * Writes a raw value to wp_options bypassing our write filter. + * + * @since x.x.x + */ + private function set_raw_option( string $option, string $value ): void { + $bridge = Key_Encryption::get_bridge(); + remove_filter( "pre_update_option_{$option}", array( $bridge, 'on_write' ), 10 ); + update_option( $option, $value ); + add_filter( "pre_update_option_{$option}", array( $bridge, 'on_write' ), 10, 1 ); + } + + /** + * Registers a fake AI connector in the WP 7.0 connector registry. + * + * @since x.x.x + */ + private function register_test_connector(): void { + $registry = \WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + $this->markTestSkipped( 'WordPress Connectors API is unavailable.' ); + } + + if ( ! $registry->is_registered( self::CONNECTOR_ID ) ) { + $registry->register( + self::CONNECTOR_ID, + array( + 'name' => 'Test Provider', + 'description' => 'Fake provider for Key_Encryption tests.', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'setting_name' => self::SETTING_NAME, + ), + ) + ); + } + } + } +} From 87930ec4720bf0dc8c39ca0ce229be44f948c098 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 14 May 2026 14:49:39 -0600 Subject: [PATCH 5/7] Add stubs for the secrets manager functions so PHPStan doesn't complain --- phpstan.neon.dist | 3 ++ .../displace-secrets-manager.php | 45 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 tests/phpstan-stubs/displace-secrets-manager.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 92306040f..d7f3f050b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -28,6 +28,9 @@ parameters: # See . - vendor/phpstan/php-8-stubs/stubs/ext/standard/str_contains.php - vendor/phpstan/php-8-stubs/stubs/ext/standard/str_starts_with.php + # Optional displace-secrets-manager plugin (https://github.com/ericmann/displace-secrets-manager). + # Used by the Key_Encryption experiment, runtime-checked via function_exists(). + - tests/phpstan-stubs/displace-secrets-manager.php # Temporary while WP 7.0 beta AI Client migration is in progress. ignoreErrors: - '#Function wp_ai_client_prompt not found\.#' diff --git a/tests/phpstan-stubs/displace-secrets-manager.php b/tests/phpstan-stubs/displace-secrets-manager.php new file mode 100644 index 000000000..ac9a6da75 --- /dev/null +++ b/tests/phpstan-stubs/displace-secrets-manager.php @@ -0,0 +1,45 @@ + $context Optional. Additional context. + * @return string|null + */ +function get_secret( string $key, array $context = array() ): ?string {} + +/** + * Store a secret value. + * + * @param string $key Namespaced secret key. + * @param string $value The plaintext secret value. + * @param array $context Optional. Additional context. + */ +function set_secret( string $key, string $value, array $context = array() ): bool {} + +/** + * Delete a secret. + * + * @param string $key Namespaced secret key. + * @param array $context Optional. Additional context. + */ +function delete_secret( string $key, array $context = array() ): bool {} + +/** + * Check whether a secret exists without retrieving its value. + * + * @param string $key Namespaced secret key. + * @param array $context Optional. Additional context. + */ +function secret_exists( string $key, array $context = array() ): bool {} From 034e9acac3829cc87176efe4408451bfb822ccec Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 14 May 2026 15:22:20 -0600 Subject: [PATCH 6/7] Ensure if the AI plugin is globally disabled, keys are decrypted --- docs/experiments/key-encryption.md | 18 +++ includes/Admin/Deactivation.php | 3 +- .../Key_Encryption/Key_Encryption.php | 127 ++++++++++++++---- .../Key_Encryption/Key_EncryptionTest.php | 99 +++++++++++--- 4 files changed, 203 insertions(+), 44 deletions(-) diff --git a/docs/experiments/key-encryption.md b/docs/experiments/key-encryption.md index c35d558f4..3b7a6f4f5 100644 --- a/docs/experiments/key-encryption.md +++ b/docs/experiments/key-encryption.md @@ -35,3 +35,21 @@ While enabled, the experiment registers two transparent option filters per conne All existing callers — `Connector_Key_Index`, REST dispatch, the AI client registry — keep working because `get_option()` transparently returns the decrypted value through the read filter. + +## Opt-in / opt-out lifecycle + +Migration is driven by the **effective** enabled state — the conjunction of the global features +toggle (`wpai_features_enabled`) and this experiment's individual toggle. Either toggle flipping +off is a transition out of "effectively enabled" and triggers the reverse migration. This matters +because when the global toggle is off the transparent read filter never gets installed at all — +without the reverse migration, the user would be locked out of their own keys. + +## Disabling the experiment + +Toggle the experiment off from the Experiments settings page. The reverse migration runs as soon +as the toggle (or the global features toggle) flips off. + +Avoid using the `wpai_feature_key-encryption_enabled` filter to force-disable this experiment: the +filter only short-circuits `is_enabled()`, so the transparent read filter is never installed — +but no toggle changes, so the reverse migration is never triggered either, and the user is locked +out of encrypted keys. Always change the stored toggle (or the global toggle) instead. diff --git a/includes/Admin/Deactivation.php b/includes/Admin/Deactivation.php index 410e6b2bc..f2f9681c4 100644 --- a/includes/Admin/Deactivation.php +++ b/includes/Admin/Deactivation.php @@ -33,8 +33,7 @@ final class Deactivation { * @since x.x.x */ public static function deactivation_callback(): void { - $option = 'wpai_feature_' . Key_Encryption::get_id() . '_enabled'; - if ( ! (bool) get_option( $option, false ) ) { + if ( ! Key_Encryption::is_effectively_enabled() ) { return; } diff --git a/includes/Experiments/Key_Encryption/Key_Encryption.php b/includes/Experiments/Key_Encryption/Key_Encryption.php index 480ae06e6..42d537453 100644 --- a/includes/Experiments/Key_Encryption/Key_Encryption.php +++ b/includes/Experiments/Key_Encryption/Key_Encryption.php @@ -11,6 +11,7 @@ use WordPress\AI\Abstracts\Abstract_Feature; use WordPress\AI\Experiments\Experiment_Category; +use WordPress\AI\Settings\Settings_Registration; // Exit if accessed directly. defined( 'ABSPATH' ) || exit; @@ -78,6 +79,7 @@ protected function load_metadata(): array { 'label' => __( 'Key Encryption', 'ai' ), 'description' => __( 'Encrypts AI provider API keys at rest using the Displace Secrets Manager plugin. Keys are transparently decrypted on read and re-encrypted on write. Disabling the experiment or deactivating the plugin restores plaintext keys.', 'ai' ), 'category' => Experiment_Category::ADMIN, + 'capability' => 'none', ); } @@ -90,64 +92,141 @@ public function register(): void { self::get_bridge()->register_option_filters(); } + /** + * Returns the option name for this experiment's individual toggle. + * + * @since x.x.x + */ + public static function get_toggle_option_name(): string { + return 'wpai_feature_' . self::get_id() . '_enabled'; + } + + /** + * Returns whether the experiment is effectively enabled (global AND individual toggle on). + * + * Does not consult `Abstract_Feature::is_enabled()` because that + * method caches per-instance, which would be stale immediately after + * a toggle change inside the same request. + * + * @since x.x.x + */ + public static function is_effectively_enabled(): bool { + $global = self::coerce_bool( get_option( Settings_Registration::GLOBAL_OPTION, false ) ); + $individual = self::coerce_bool( get_option( self::get_toggle_option_name(), false ) ); + return $global && $individual; + } + /** * {@inheritDoc} * * @since x.x.x */ public function register_settings(): void { - $option = 'wpai_feature_' . self::get_id() . '_enabled'; + $individual = self::get_toggle_option_name(); + $global = Settings_Registration::GLOBAL_OPTION; - if ( false === has_action( "update_option_{$option}", array( self::class, 'handle_toggle_update' ) ) ) { - add_action( "update_option_{$option}", array( self::class, 'handle_toggle_update' ), 10, 2 ); - } + self::ensure_action( "update_option_{$individual}", array( self::class, 'handle_individual_toggle_update' ), 2 ); + self::ensure_action( "add_option_{$individual}", array( self::class, 'handle_individual_toggle_add' ), 2 ); + + self::ensure_action( "update_option_{$global}", array( self::class, 'handle_global_toggle_update' ), 2 ); + self::ensure_action( "add_option_{$global}", array( self::class, 'handle_global_toggle_add' ), 2 ); + } - if ( false !== has_action( "add_option_{$option}", array( self::class, 'handle_toggle_add' ) ) ) { + /** + * Idempotent `add_action` wrapper used for the toggle hooks. + * + * @since x.x.x + * + * @param string $hook Hook name. + * @param callable $callback Callback to register. + * @param int $accepted_args Number of accepted args. + */ + private static function ensure_action( string $hook, callable $callback, int $accepted_args ): void { + if ( false !== has_action( $hook, $callback ) ) { return; } - - add_action( "add_option_{$option}", array( self::class, 'handle_toggle_add' ), 10, 2 ); + add_action( $hook, $callback, 10, $accepted_args ); } /** - * Static handler for the toggle update action. + * Handles updates to this experiment's individual toggle. * * @since x.x.x * * @param mixed $old_value Previous option value. * @param mixed $new_value New option value. */ - public static function handle_toggle_update( $old_value, $new_value ): void { - $was_enabled = self::coerce_bool( $old_value ); - $is_enabled = self::coerce_bool( $new_value ); - - if ( $was_enabled === $is_enabled ) { - return; - } + public static function handle_individual_toggle_update( $old_value, $new_value ): void { + $global = self::coerce_bool( get_option( Settings_Registration::GLOBAL_OPTION, false ) ); + $was_on = $global && self::coerce_bool( $old_value ); + $now_on = $global && self::coerce_bool( $new_value ); + self::sync_effective_state( $was_on, $now_on ); + } - if ( $is_enabled ) { - self::get_bridge()->encrypt_all(); - return; - } + /** + * Handles the first-time write of this experiment's individual toggle. + * + * @since x.x.x + * + * @param string $option Option name. + * @param mixed $new_value New option value. + */ + public static function handle_individual_toggle_add( $option, $new_value ): void { + unset( $option ); + $global = self::coerce_bool( get_option( Settings_Registration::GLOBAL_OPTION, false ) ); + $now_on = $global && self::coerce_bool( $new_value ); + self::sync_effective_state( false, $now_on ); + } - self::get_bridge()->decrypt_all(); + /** + * Handles updates to the global features toggle. + * + * @since x.x.x + * + * @param mixed $old_value Previous option value. + * @param mixed $new_value New option value. + */ + public static function handle_global_toggle_update( $old_value, $new_value ): void { + $individual = self::coerce_bool( get_option( self::get_toggle_option_name(), false ) ); + $was_on = self::coerce_bool( $old_value ) && $individual; + $now_on = self::coerce_bool( $new_value ) && $individual; + self::sync_effective_state( $was_on, $now_on ); } /** - * Static handler for the toggle add action. + * Handles the first-time write of the global features toggle. * * @since x.x.x * * @param string $option Option name. * @param mixed $new_value New option value. */ - public static function handle_toggle_add( $option, $new_value ): void { + public static function handle_global_toggle_add( $option, $new_value ): void { unset( $option ); - if ( ! self::coerce_bool( $new_value ) ) { + $individual = self::coerce_bool( get_option( self::get_toggle_option_name(), false ) ); + $now_on = self::coerce_bool( $new_value ) && $individual; + self::sync_effective_state( false, $now_on ); + } + + /** + * Drives encrypt/decrypt migration when the effective enabled state transitions. + * + * @since x.x.x + * + * @param bool $was_enabled Previous effective state. + * @param bool $is_enabled New effective state. + */ + private static function sync_effective_state( bool $was_enabled, bool $is_enabled ): void { + if ( $was_enabled === $is_enabled ) { return; } - self::get_bridge()->encrypt_all(); + if ( $is_enabled ) { + self::get_bridge()->encrypt_all(); + return; + } + + self::get_bridge()->decrypt_all(); } /** diff --git a/tests/Integration/Includes/Experiments/Key_Encryption/Key_EncryptionTest.php b/tests/Integration/Includes/Experiments/Key_Encryption/Key_EncryptionTest.php index badd2a454..512be30cb 100644 --- a/tests/Integration/Includes/Experiments/Key_Encryption/Key_EncryptionTest.php +++ b/tests/Integration/Includes/Experiments/Key_Encryption/Key_EncryptionTest.php @@ -60,10 +60,11 @@ function delete_secret( string $key ): bool { */ class Key_EncryptionTest extends WP_UnitTestCase { - private const CONNECTOR_ID = 'testprovider'; - private const SETTING_NAME = 'connectors_ai_testprovider_api_key'; - private const SECRET_KEY = 'ai/testprovider_api_key'; - private const TOGGLE = 'wpai_feature_key-encryption_enabled'; + private const CONNECTOR_ID = 'testprovider'; + private const SETTING_NAME = 'connectors_ai_testprovider_api_key'; + private const SECRET_KEY = 'ai/testprovider_api_key'; + private const TOGGLE = 'wpai_feature_key-encryption_enabled'; + private const GLOBAL_TOGGLE = 'wpai_features_enabled'; /** * @var Key_Encryption @@ -86,36 +87,30 @@ public function setUp(): void { delete_option( self::SETTING_NAME ); delete_option( self::TOGGLE ); - - update_option( 'wpai_features_enabled', true ); + delete_option( self::GLOBAL_TOGGLE ); // The plugin's normal boot flow has already instantiated the experiment and wired its - // toggle hooks via Settings_Registration. We just need a reference for read-bypass - // helpers; the singleton bridge accessor returns the same bridge those hooks use. + // toggle hooks via Settings_Registration. Re-running register_settings on a fresh + // instance is safe — the inner has_action checks make it idempotent. $this->experiment = new Key_Encryption(); $this->experiment->register_settings(); + + // Enable the global toggle as the baseline for every test. Setting it last means the + // add_option handler sees individual=false and is a no-op, leaving us in a clean state. + update_option( self::GLOBAL_TOGGLE, true ); } /** * @since x.x.x */ public function tearDown(): void { - remove_all_actions( "update_option_{$this->toggle()}" ); - remove_all_actions( "add_option_{$this->toggle()}" ); - delete_option( 'wpai_features_enabled' ); + delete_option( self::GLOBAL_TOGGLE ); delete_option( self::TOGGLE ); delete_option( self::SETTING_NAME ); $GLOBALS['wpai_test_secret_store'] = array(); parent::tearDown(); } - /** - * @since x.x.x - */ - private function toggle(): string { - return self::TOGGLE; - } - /** * @since x.x.x */ @@ -194,6 +189,74 @@ public function test_write_with_empty_string_clears_secret() { $this->assertSame( '', $this->raw_option( self::SETTING_NAME ) ); } + /** + * The user globally disables AI features while Key Encryption is on. Existing encrypted + * keys must be restored to plaintext so the user is not locked out. + * + * @since x.x.x + */ + public function test_global_toggle_off_decrypts_existing_keys() { + update_option( self::TOGGLE, true ); + update_option( self::SETTING_NAME, 'sk-global-off' ); + $this->assertArrayHasKey( self::SECRET_KEY, $GLOBALS['wpai_test_secret_store'] ); + + update_option( self::GLOBAL_TOGGLE, false ); + + $this->assertSame( 'sk-global-off', $this->raw_option( self::SETTING_NAME ) ); + $this->assertArrayNotHasKey( self::SECRET_KEY, $GLOBALS['wpai_test_secret_store'] ); + } + + /** + * Re-enabling the global toggle (with the experiment still individually on) re-encrypts + * the plaintext keys that were restored when the global toggle was flipped off. + * + * @since x.x.x + */ + public function test_global_toggle_on_re_encrypts() { + update_option( self::TOGGLE, true ); + update_option( self::SETTING_NAME, 'sk-round-trip' ); + + update_option( self::GLOBAL_TOGGLE, false ); + $this->assertSame( 'sk-round-trip', $this->raw_option( self::SETTING_NAME ) ); + + update_option( self::GLOBAL_TOGGLE, true ); + + $this->assertSame( '', $this->raw_option( self::SETTING_NAME ) ); + $this->assertSame( 'sk-round-trip', $GLOBALS['wpai_test_secret_store'][ self::SECRET_KEY ] ?? null ); + } + + /** + * Toggling the experiment on while AI is globally disabled is a no-op for migration — + * there is no point encrypting if the read filter will not run on the next request. + * + * @since x.x.x + */ + public function test_individual_toggle_on_while_global_off_is_noop() { + update_option( self::GLOBAL_TOGGLE, false ); + update_option( self::SETTING_NAME, 'sk-globally-off' ); + + update_option( self::TOGGLE, true ); + + $this->assertSame( 'sk-globally-off', $this->raw_option( self::SETTING_NAME ) ); + $this->assertArrayNotHasKey( self::SECRET_KEY, $GLOBALS['wpai_test_secret_store'] ); + } + + /** + * Deactivation reads the *effective* state. If the global toggle is already off the secrets + * have already been restored, so deactivation has nothing to do. + * + * @since x.x.x + */ + public function test_deactivation_noop_when_globally_disabled() { + update_option( self::TOGGLE, true ); + update_option( self::SETTING_NAME, 'sk-still-plaintext' ); + update_option( self::GLOBAL_TOGGLE, false ); + + Deactivation::deactivation_callback(); + + $this->assertSame( 'sk-still-plaintext', $this->raw_option( self::SETTING_NAME ) ); + } + /** * @since x.x.x */ From 50546350522bcf73e9a1483e40332fd5bdb4b402 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 14 May 2026 15:46:34 -0600 Subject: [PATCH 7/7] Ensure if someone has Key Encryption turned on and they deactivate then reactivate the plugin, we re-encrypt their keys --- includes/Admin/Activation.php | 5 ++ .../Key_Encryption/Key_Encryption.php | 58 +++++++++++++++++-- .../Key_Encryption/Secrets_Bridge.php | 14 ++++- .../Key_Encryption/Key_EncryptionTest.php | 55 ++++++++++++++++++ 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/includes/Admin/Activation.php b/includes/Admin/Activation.php index 24e7c6a6b..878ce29eb 100644 --- a/includes/Admin/Activation.php +++ b/includes/Admin/Activation.php @@ -10,6 +10,8 @@ namespace WordPress\AI\Admin; +use WordPress\AI\Experiments\Key_Encryption\Key_Encryption; + // Exit if accessed directly. defined( 'ABSPATH' ) || exit; @@ -29,5 +31,8 @@ final class Activation { public static function activation_callback(): void { // Check and run any pending upgrades. Upgrades::do_upgrades(); + + // Schedule the Key Encryption experiment to re-encrypt plaintext keys on the next request. + Key_Encryption::flag_resume_migration(); } } diff --git a/includes/Experiments/Key_Encryption/Key_Encryption.php b/includes/Experiments/Key_Encryption/Key_Encryption.php index 42d537453..518ff15fb 100644 --- a/includes/Experiments/Key_Encryption/Key_Encryption.php +++ b/includes/Experiments/Key_Encryption/Key_Encryption.php @@ -29,6 +29,16 @@ */ class Key_Encryption extends Abstract_Feature { + /** + * Option that records re-encryption is needed on the next request. + * + * Set by `Activation::activation_callback()` so re-activating the plugin while the experiment + * is still toggled on re-encrypts the plaintext keys that the previous deactivation restored. + * + * @since x.x.x + */ + public const RESUME_MIGRATION_OPTION = 'wpai_key_encryption_resume_migration'; + /** * Process-wide bridge instance. * @@ -125,11 +135,46 @@ public function register_settings(): void { $individual = self::get_toggle_option_name(); $global = Settings_Registration::GLOBAL_OPTION; - self::ensure_action( "update_option_{$individual}", array( self::class, 'handle_individual_toggle_update' ), 2 ); - self::ensure_action( "add_option_{$individual}", array( self::class, 'handle_individual_toggle_add' ), 2 ); + self::ensure_action( "update_option_{$individual}", array( self::class, 'handle_individual_toggle_update' ), 10, 2 ); + self::ensure_action( "add_option_{$individual}", array( self::class, 'handle_individual_toggle_add' ), 10, 2 ); + + self::ensure_action( "update_option_{$global}", array( self::class, 'handle_global_toggle_update' ), 10, 2 ); + self::ensure_action( "add_option_{$global}", array( self::class, 'handle_global_toggle_add' ), 10, 2 ); + + // Process any deferred re-encryption flagged by the activation hook. Priority 16 runs + // after `_wp_connectors_init` (priority 15), so `get_ai_connectors()` is populated. + self::ensure_action( 'init', array( self::class, 'maybe_resume_migration' ), 16, 0 ); + } + + /** + * Sets the deferred-migration flag. + * + * Called from the plugin activation hook so the migration runs + * on the next request, when the connector registry has been populated. + * + * @since x.x.x + */ + public static function flag_resume_migration(): void { + update_option( self::RESUME_MIGRATION_OPTION, '1', false ); + } + + /** + * Consumes the deferred-migration flag and re-encrypts plaintext keys if effectively enabled. + * + * @since x.x.x + */ + public static function maybe_resume_migration(): void { + if ( '1' !== get_option( self::RESUME_MIGRATION_OPTION, '' ) ) { + return; + } + + delete_option( self::RESUME_MIGRATION_OPTION ); + + if ( ! self::is_effectively_enabled() ) { + return; + } - self::ensure_action( "update_option_{$global}", array( self::class, 'handle_global_toggle_update' ), 2 ); - self::ensure_action( "add_option_{$global}", array( self::class, 'handle_global_toggle_add' ), 2 ); + self::get_bridge()->encrypt_all(); } /** @@ -139,13 +184,14 @@ public function register_settings(): void { * * @param string $hook Hook name. * @param callable $callback Callback to register. + * @param int $priority Hook priority. * @param int $accepted_args Number of accepted args. */ - private static function ensure_action( string $hook, callable $callback, int $accepted_args ): void { + private static function ensure_action( string $hook, callable $callback, int $priority, int $accepted_args ): void { if ( false !== has_action( $hook, $callback ) ) { return; } - add_action( $hook, $callback, 10, $accepted_args ); + add_action( $hook, $callback, $priority, $accepted_args ); } /** diff --git a/includes/Experiments/Key_Encryption/Secrets_Bridge.php b/includes/Experiments/Key_Encryption/Secrets_Bridge.php index d96c6e037..fd78f6de5 100644 --- a/includes/Experiments/Key_Encryption/Secrets_Bridge.php +++ b/includes/Experiments/Key_Encryption/Secrets_Bridge.php @@ -99,6 +99,11 @@ public function encrypt_all(): int { return 0; } + // Tear down filters first so the `update_option` calls below + // don't get intercepted by `on_write` which would "helpfully" + // delete the secret we just stored. + $this->unregister_option_filters(); + $count = 0; foreach ( $this->get_connector_setting_names() as $connector_id => $setting_name ) { $plaintext = $this->read_raw_option( $setting_name ); @@ -106,11 +111,18 @@ public function encrypt_all(): int { continue; } - $stored = set_secret( $this->secret_key( $connector_id ), $plaintext ); + $secret_key = $this->secret_key( $connector_id ); + + $stored = set_secret( $secret_key, $plaintext ); if ( ! $stored ) { continue; } + // Verify the secret actually persisted before we drop the plaintext. + if ( get_secret( $secret_key ) !== $plaintext ) { + continue; + } + update_option( $setting_name, '' ); ++$count; } diff --git a/tests/Integration/Includes/Experiments/Key_Encryption/Key_EncryptionTest.php b/tests/Integration/Includes/Experiments/Key_Encryption/Key_EncryptionTest.php index 512be30cb..9d00e0154 100644 --- a/tests/Integration/Includes/Experiments/Key_Encryption/Key_EncryptionTest.php +++ b/tests/Integration/Includes/Experiments/Key_Encryption/Key_EncryptionTest.php @@ -107,6 +107,7 @@ public function tearDown(): void { delete_option( self::GLOBAL_TOGGLE ); delete_option( self::TOGGLE ); delete_option( self::SETTING_NAME ); + delete_option( Key_Encryption::RESUME_MIGRATION_OPTION ); $GLOBALS['wpai_test_secret_store'] = array(); parent::tearDown(); } @@ -257,6 +258,60 @@ public function test_deactivation_noop_when_globally_disabled() { $this->assertSame( 'sk-still-plaintext', $this->raw_option( self::SETTING_NAME ) ); } + /** + * Plugin lifecycle: deactivate decrypts; reactivate (via the deferred resume flag) re-encrypts. + * + * @since x.x.x + */ + public function test_reactivation_re_encrypts_plaintext_keys() { + update_option( self::TOGGLE, true ); + update_option( self::SETTING_NAME, 'sk-roundtrip' ); + $this->assertArrayHasKey( self::SECRET_KEY, $GLOBALS['wpai_test_secret_store'] ); + + // Simulate deactivation: keys decrypted, secret cleared. + Deactivation::deactivation_callback(); + $this->assertSame( 'sk-roundtrip', $this->raw_option( self::SETTING_NAME ) ); + $this->assertArrayNotHasKey( self::SECRET_KEY, $GLOBALS['wpai_test_secret_store'] ); + + // Simulate reactivation: activation hook sets the deferred flag. + Key_Encryption::flag_resume_migration(); + $this->assertSame( '1', get_option( Key_Encryption::RESUME_MIGRATION_OPTION ) ); + + // On the next request, `register()` runs first (because the feature is effectively + // enabled) and wires the option filters BEFORE init+16 fires the deferred migration. + // Simulate that ordering — `encrypt_all` must defang those filters during its own + // run, otherwise its `update_option( $setting, '' )` call gets intercepted by the + // write filter and the just-stored secret is deleted right back out. + Key_Encryption::get_bridge()->register_option_filters(); + + // Simulate init+16. + Key_Encryption::maybe_resume_migration(); + + $this->assertSame( '', $this->raw_option( self::SETTING_NAME ) ); + $this->assertSame( 'sk-roundtrip', $GLOBALS['wpai_test_secret_store'][ self::SECRET_KEY ] ?? null ); + $this->assertFalse( get_option( Key_Encryption::RESUME_MIGRATION_OPTION, false ) ); + + // And the read filter still works after the migration. + $this->assertSame( 'sk-roundtrip', get_option( self::SETTING_NAME ) ); + } + + /** + * Fresh activation with the experiment never enabled is a no-op: the flag is consumed but + * no migration runs. + * + * @since x.x.x + */ + public function test_resume_migration_noop_when_not_effectively_enabled() { + update_option( self::SETTING_NAME, 'sk-plaintext' ); + Key_Encryption::flag_resume_migration(); + + Key_Encryption::maybe_resume_migration(); + + $this->assertSame( 'sk-plaintext', $this->raw_option( self::SETTING_NAME ) ); + $this->assertArrayNotHasKey( self::SECRET_KEY, $GLOBALS['wpai_test_secret_store'] ); + $this->assertFalse( get_option( Key_Encryption::RESUME_MIGRATION_OPTION, false ) ); + } + /** * @since x.x.x */