Skip to content

Fix: Key Encryption: undecryptable secrets cause a fatal error on every request, and deactivation fails - #1186

Merged
dkotter merged 1 commit into
WordPress:developfrom
hbhalodia:fix/issue-1185
Oct 16, 2026
Merged

dkotter merged 1 commit into
WordPress:developfrom
hbhalodia:fix/issue-1185

Conversation

@hbhalodia

@hbhalodia hbhalodia commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What?

Closes #1185

Stops an undecryptable Key Encryption secret from causing a fatal error. Previously this broke every request and plugin deactivation.

Why?

Changing LOGGED_IN_KEY / LOGGED_IN_SALT (salt shuffle, "log out everyone", or migration) leaves the stored master key undecryptable. Secrets_Bridge called Secrets::get() / Secrets::set() without handling errors, so the Secrets_Exception reached get_option(). The site fataled on every request, and wp plugin deactivate ai fataled too. Separately, on_write() ignored a failed Secrets::set(), so a newly saved key was silently dropped.

How?

All changes are in Secrets_Bridge:

  • New private get_secret() / set_secret() wrappers catch \Throwable and treat a failure as "no secret" or "not stored". Failures are logged when WP_DEBUG is on.
  • Read: falls back to the stored option value, so the site keeps working and the connector shows no key.
  • Write: if the secret can't be stored, the key is saved as plaintext instead of being dropped. The old secret is removed so it can't shadow the new key.
  • Deactivation and opt-out (decrypt_all()): undecryptable secrets are skipped and left in place, so restoring the old salts can still recover them.
  • Re-activation (encrypt_all()): keys that can't be encrypted stay plaintext.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5
Used for: Tracing the call chain, implementing the fix, and writing the tests. I reviewed and tested all changes.

Testing Instructions

  1. npm run wp-env start, enable Key Encryption, and save an API key under Settings → Connectors.
  2. Break decryption: npm run wp-env:cli wp config set WP_SECRETS_KEY repro-1185
  3. Load any front-end or admin page. Before: fatal Cannot decrypt master key. After: the page loads and the connector shows no key.
  4. Re-save the API key. It's saved, and the connector works.
  5. npm run wp-env:cli wp plugin deactivate ai completes without a fatal.
  6. Clean up: npm run wp-env:cli wp config delete WP_SECRETS_KEY --skip-plugins
  7. Automated: npm run test:php -- --filter Key_EncryptionTest. The 4 new tests fail on develop and pass with this change.

Changelog Entry

Fixed - Key Encryption no longer causes a fatal error on every request or during deactivation when stored API keys can't be decrypted (e.g. after changing WordPress salts).

Open WordPress Playground Preview

@hbhalodia
hbhalodia marked this pull request as ready for review October 15, 2026 09:34
@hbhalodia
hbhalodia requested a review from a team October 15, 2026 09:34
@github-actions

github-actions Bot commented Oct 15, 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: hbhalodia <hbhalodia@git.wordpress.org>
Co-authored-by: dhrupo <dhrupo@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 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.55172% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 82.28%. Comparing base (5f69e26) to head (99e22f7).

Files with missing lines Patch % Lines
...udes/Experiments/Key_Encryption/Secrets_Bridge.php 96.55% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop    #1186      +/-   ##
=============================================
+ Coverage      82.25%   82.28%   +0.02%     
- Complexity      3403     3411       +8     
=============================================
  Files            136      136              
  Lines          13225    13245      +20     
=============================================
+ Hits           10878    10898      +20     
  Misses          2347     2347              
Flag Coverage Δ
unit 82.28% <96.55%> (+0.02%) ⬆️

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.

@dhrupo dhrupo left a comment

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.

Tested 99e22f7 on WordPress 7.2-alpha-63166-src (wordpress-develop trunk 7d64ecfeaf, plus one unrelated KSES change), PHP 8.3.30, with AI Provider for OpenAI active. I enabled Key Encryption, saved a dummy OpenAI key, and then broke decryption by defining WP_SECRETS_KEY (as in the testing instructions).

Before (develop d6b6ad7):

  • Every front-end request fatals with Uncaught Secrets_Exception: Cannot decrypt master key. The stack matches the issue: Main::register_provider_data() → has_ai_credentials() → get_option() → Secrets_Bridge::on_read() → Secrets::get(). Core's _wp_connectors_pass_default_keys_to_ai_client() also hits it and shows a "Cannot decrypt master key" notice.
  • wp plugin deactivate ai fatals the same way.

After (this PR):

  • Front end: HTTP 200 on three consecutive requests, with no fatal and no notice.
  • wp plugin deactivate ai succeeds. The encrypted secret is left in place, and after removing WP_SECRETS_KEY and reactivating, get_option( 'connectors_ai_openai_api_key' ) returns the original key again. So the recovery path works end to end.
  • Re-saving a key while decryption is broken: the new key is stored in wp_options and used, and the old secret row is removed, as described.
  • Key_EncryptionTest: 16 tests pass. With develop's Secrets_Bridge.php swapped in, exactly the 4 new tests error. CI is green, and the branch still merges cleanly into current develop.

I also checked the calls that stay unwrapped. Secrets::delete() in the on_write() fallback and in delete_secret_for_current_filter() ends in Secrets_Provider_Encrypted_Options::delete(), which is just delete_option() and never touches the master key, so it can't throw here. Provider selection only checks is_available() (sodium), so it doesn't decrypt anything either.

A few non-blocking notes, mostly for a follow-up:

  1. Plaintext after recovery. After the store works again, a key saved during the outage stays plaintext in wp_options while Key Encryption still reads as enabled. Nothing re-encrypts it until the experiment is toggled or the plugin is reactivated, and the site owner isn't told. The admin notice @hbhalodia suggested on the issue would cover this, and re-running encrypt_all() once the provider's health check passes again could close the gap.
  2. Logging. Failures are only logged when WP_DEBUG is on, so on a production site nothing explains why the connector suddenly has no key. That's the same follow-up notice.
  3. From reading the code (not reproduced): derive_secrets_key() concatenates LOGGED_IN_KEY . LOGGED_IN_SALT without a defined() check. Catching \Throwable here covers that on the read and write paths, but the provider's health_check() only catches Secrets_Exception, so a site without those constants could still fatal wherever the health check runs. It's a rare setup, so probably follow-up material along with rewording the exception message (both are listed in the issue).

AI disclosure: I used Claude Code to help set up the reproduction and draft this review. I ran and checked every result above myself.

@dhrupo

dhrupo commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Screenshots for the review above (front end with WP_SECRETS_KEY changed after enabling Key Encryption):

Before (develop):

1185-before-develop

After (this PR):

1185-after-pr

@dkotter dkotter added this to the 1.5.0 milestone Oct 16, 2026
@dkotter
dkotter merged commit a0b5dd5 into WordPress:develop Oct 16, 2026
38 checks passed
whyisjake added a commit to Pranjal1423/ai that referenced this pull request Oct 16, 2026
Resolves the conflict with WordPress#1186 in Secrets_Bridge and its tests:
- keeps this branch's get_secret()/set_secret()/delete_secret();
- on_write() keeps the plaintext and deletes a stale secret when a key
  can't be stored, as WordPress#1186 does;
- the legacy Secrets::set() fallback no longer throws when the master
  key can't be decrypted;
- WordPress#1186's tests break core's root key as well as the legacy master key,
  since writes go through the Secrets API first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pranjal1423 pushed a commit to Pranjal1423/ai that referenced this pull request Oct 19, 2026
…e's legacy reader

Keys stored before 1.5.0 are read once with the bundled read-only
Secrets_API_Legacy_Reader and moved to the Secrets API, so the
Displace classes and their tests can go. set_secret() no longer
falls back to writing a legacy row. WordPress#1186's tests now break only
core's root key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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: undecryptable secrets cause a fatal error on every request, and deactivation fails

3 participants