Skip to content

Key Encryption: Restore API keys on every site on network deactivation and before uninstall - #1118

Open
maheshbohara wants to merge 10 commits into
WordPress:developfrom
maheshbohara:fix/1117-key-encryption-multisite-deactivation
Open

maheshbohara wants to merge 10 commits into
WordPress:developfrom
maheshbohara:fix/1117-key-encryption-multisite-deactivation

Conversation

@maheshbohara

@maheshbohara maheshbohara commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

What?

Closes #1117

On multisite, restores every site's API keys when the plugin is network-deactivated with Key Encryption enabled, and stops uninstall from deleting keys it has not restored.

Why?

Deactivation::deactivation_callback() ignored the $network_wide argument and decrypted the current site only. Every other site with the experiment enabled was left with an empty connectors_*_api_key option and its key locked in _secret_ai/*. Uninstall::run() then deleted those rows on every site, so the keys were lost for good. Steps and database state are in #1117.

How?

  • Key_Encryption::for_each_site(): runs a callback on the current site, or on every site when the routine runs for the whole network. Each site has its own _secrets_master_key, and the provider caches the first one it loads for the rest of the request, so the cache is dropped around each call. A callback that throws does not stop the other sites. What it threw is returned, keyed by site ID.
  • Deactivation: deactivation_callback() accepts $network_wide and decrypts, through for_each_site(), each site that has the experiment enabled. decrypt_all() now restores every key it can and then throws a Key_Decryption_Exception that lists the connectors it could not decrypt. If any site reports one, deactivation stops with a wp_die() message that names the sites and connectors, as it stopped on develop with an uncaught exception. The plugin stays active, so the keys that were restored are flagged to be encrypted again on the next request.
  • Activation: activation_callback() accepts $network_wide and flags every site for re-encryption. Without this, a network deactivate and reactivate would leave the other sites' keys in plaintext while the experiment is still shown as enabled.
  • Uninstall: any connector keys still in the secrets store are written back to their connector options. This runs before the wpai_remove_data_on_uninstall filter is checked, so a site that keeps the plugin's data gets its keys back too. It covers sites the deactivation routine never ran for. A key that cannot be decrypted is skipped there, because stopping would abort the deletion halfway. helpers.php is loaded there because the plugin is not bootstrapped during uninstall and Secrets_Bridge needs get_ai_connectors().
  • Secrets_Bridge::reset_provider(): drops the cached master key. It calls Secrets_Manager::reset(), which the vendored SDK documents as "for testing only". I did not find another way to drop the cached key without changing the vendored code. I am happy to do this differently if you prefer.
  • Both hooks take ?bool $network_wide, because WordPress does not always pass a boolean.

Two things to know:

  • Uninstall restores keys only for connectors that are registered at that point, the same as deactivation. A secret for a connector whose provider plugin is already gone is deleted as before.
  • The deactivation message asks the admin to remove the listed keys under Settings > Connectors and enter them again. While the plugin is active, a key that cannot be decrypted also makes the read filters throw, so that screen fails to load on the affected site. This is the same on develop and is not changed here. I have a small follow-up that makes such a key read as not set, and I can add it to this PR or open a separate one.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus
Used for: Investigation, implementation, tests, manual testing, and PR wording. I reviewed the changes and the test results, and I take responsibility for the contribution.

Testing Instructions

  1. On a multisite network, network-activate the AI plugin and a provider plugin (I used AI Provider for OpenAI).
  2. On the main site and on a second site, enable Key Encryption under Settings > AI, then save an API key for OpenAI under Settings > Connectors. Both show "Connected".
  3. In Network Admin > Plugins, click Network Deactivate on the AI plugin.
  4. Open Settings > Connectors on the second site. It still shows "Connected". On develop it is back to "Set up". wp option get connectors_ai_openai_api_key --url=<site 2> prints the key, and there is no _secret_ai/openai_api_key row.
  5. Network-activate the plugin again and open any admin page on each site. The connector options are empty again and the _secret_ai/openai_api_key rows are back.
  6. Network-deactivate and delete the plugin. Both sites still have their key in connectors_ai_openai_api_key.

To test the uninstall change on its own, deactivate with wp plugin deactivate ai --network --skip-plugins so the deactivation routine does not run, then delete the plugin from Network Admin > Plugins. Both sites keep their key.

To test the message, damage one stored key on the second site, for example with wp db query "UPDATE wp_2_options SET option_value='x' WHERE option_name='_secret_ai/openai_api_key'", then click Network Deactivate. The deactivation stops with a page that names the site and the connector, the plugin stays active, and the other keys on the network are restored and then encrypted again.

To test the opt-out case, add add_filter( 'wpai_remove_data_on_uninstall', '__return_false' ); in a must-use plugin and repeat the uninstall steps above. The sites keep the plugin's data and get their keys back.

I ran these steps on WordPress 7.1.2 multisite (wp-env, PHP 8.3) with dummy keys. Core checks a key against the provider when it is saved, so I answered that one request locally with a pre_http_request filter. A real key does not need that.

Automated:

  • 18 new tests in ActivationTest, DeactivationTest, UninstallTest and Key_EncryptionTest.
  • npm run test:php and npm run test:php:multisite pass (1786 tests each).
  • composer lint, PHPStan, npm run typecheck and npm run lint:js pass.
  • I did not run the e2e suite locally. No JS changed, and it passes in CI.
  • Four changed lines are not reached by the single-site coverage job: get_sites() and restore_current_blog(), which only run on multisite and are covered by the multisite jobs, the fallback for an error other than a Key_Decryption_Exception in the deactivation message, and the ABSPATH guard of the new exception file.

Changelog Entry

Fixed - Key Encryption: on multisite, network deactivation restores API keys on every site, and uninstall no longer deletes keys that were still encrypted. Deactivation stops with a message when a key cannot be decrypted.

Open WordPress Playground Preview

Every site in a network has its own master key, and the encryption provider keeps the first one it loads for the rest of the request. Secrets_Bridge::reset_provider() drops it so code that loops over sites reads and writes each site's secrets with that site's key.
…ivation

The deactivation routine ignored the $network_wide argument and decrypted the current site only. Every other site with the experiment enabled was left with an empty connector option and its key locked in the secrets store.

See WordPress#1117.
The connector API keys belong to the Connectors screen, not to this plugin. Uninstall deleted the encrypted copies on every site, which lost the key for good on any site the deactivation routine had not run for.

Keys are left untouched when a site opts out with the wpai_remove_data_on_uninstall filter, as before.

See WordPress#1117.
Network deactivation now restores plaintext keys on every site, so network activation has to schedule the re-encryption on every site too. Before, only the current site was flagged.

See WordPress#1117.
@maheshbohara
maheshbohara requested a review from a team October 6, 2026 07:46
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: maheshbohara <maheshbohara@git.wordpress.org>
Co-authored-by: dkotter <dkotter@git.wordpress.org>
Co-authored-by: jeffpaul <jeffpaul@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.80519% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.17%. Comparing base (4fc2d4a) to head (ebc6afd).

Files with missing lines Patch % Lines
...udes/Experiments/Key_Encryption/Key_Encryption.php 88.23% 2 Missing ⚠️
includes/Admin/Deactivation.php 96.66% 1 Missing ⚠️
...iments/Key_Encryption/Key_Decryption_Exception.php 85.71% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop    #1118      +/-   ##
=============================================
+ Coverage      82.11%   82.17%   +0.06%     
- Complexity      3394     3417      +23     
=============================================
  Files            136      137       +1     
  Lines          13178    13249      +71     
=============================================
+ Hits           10821    10888      +67     
- Misses          2357     2361       +4     
Flag Coverage Δ
unit 82.17% <94.80%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

WordPress does not always pass a boolean to the activation and deactivation hooks. A null made the new bool parameter throw a TypeError and stopped the plugin from activating.

See WordPress#1117.
…vation

Activation and deactivation each had their own loop over the network's sites, and neither could run on single site, so the coverage job never reached them. Key_Encryption::for_each_site() now holds the loop and runs the same code for one site or for all of them.

A callback that throws is skipped on single site too, so a key that cannot be decrypted no longer blocks deactivation. Uninstall no longer checks for wp_get_connectors(), which always exists on the supported WordPress versions, and has a test for a key that cannot be decrypted.

See WordPress#1117.
@dkotter dkotter added this to the 1.5.0 milestone Oct 6, 2026
try {
$callback();
} catch ( Throwable $e ) {
// A site whose secrets cannot be read must not keep the other sites

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a behavior change compared to previous, where an error would stop the deactivation process. Seems we should consider at least showing an error message if an error happens since that means decryption failed and API keys are no longer accessible, so alerting the user would be nice

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dkotter Good catch, that was not intended. Deactivation now stops again when a key cannot be decrypted, with a message that names the sites and connectors instead of an uncaught exception.


// The API keys belong to the Connectors screen, not to this plugin, so they
// are put back before the encrypted copies are deleted with the options.
self::restore_encrypted_keys();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be moved before the above filter? The purpose of that filter is to allow sites to opt out of data removal, though technically this isn't removing data, it's restoring data. So seems like it may be best if this always runs, regardless of the result of that filter

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dkotter Agreed, moved. Keys are now restored before the filter is checked, so a site that keeps the plugin's data gets its keys back too. I updated the filter's docblock and the test.

… decrypted

A key that could not be decrypted was skipped without a word, and the plugin was deactivated anyway. It also kept the keys after it from being restored, although nothing was wrong with them.

decrypt_all() now restores every key it can and reports the ones it could not. Deactivation still goes through every site, then stops with a message that names the sites and connectors concerned. The plugin stays active, so the keys that were restored are flagged to be encrypted again.

See WordPress#1117.
A site that opted out with the wpai_remove_data_on_uninstall filter kept its keys encrypted after the plugin was deleted, with nothing left to decrypt them. Restoring a key is not removing data, so it now happens before the filter is checked.

See WordPress#1117.
@maheshbohara
maheshbohara requested a review from dkotter October 7, 2026 07:14
@jeffpaul

jeffpaul commented Oct 8, 2026

Copy link
Copy Markdown
Member

@maheshbohara a merge conflict to resolve, please and thanks!

@maheshbohara

Copy link
Copy Markdown
Contributor Author

@jeffpaul Resolved the merge conflict. Thanks for the heads-up!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Key Encryption: network deactivation restores keys on the current site only, and uninstall then deletes the other sites' API keys

3 participants