Repository navigation
Fix: Key Encryption: undecryptable secrets cause a fatal error on every request, and deactivation fails - #1186
Conversation
|
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 If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
dhrupo
left a comment
There was a problem hiding this comment.
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 aifatals the same way.
After (this PR):
- Front end: HTTP 200 on three consecutive requests, with no fatal and no notice.
wp plugin deactivate aisucceeds. The encrypted secret is left in place, and after removingWP_SECRETS_KEYand 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_optionsand used, and the old secret row is removed, as described. Key_EncryptionTest: 16 tests pass. With develop'sSecrets_Bridge.phpswapped in, exactly the 4 new tests error. CI is green, and the branch still merges cleanly into currentdevelop.
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:
- Plaintext after recovery. After the store works again, a key saved during the outage stays plaintext in
wp_optionswhile 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-runningencrypt_all()once the provider's health check passes again could close the gap. - Logging. Failures are only logged when
WP_DEBUGis on, so on a production site nothing explains why the connector suddenly has no key. That's the same follow-up notice. - From reading the code (not reproduced):
derive_secrets_key()concatenatesLOGGED_IN_KEY . LOGGED_IN_SALTwithout adefined()check. Catching\Throwablehere covers that on the read and write paths, but the provider'shealth_check()only catchesSecrets_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.
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>
…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>


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_BridgecalledSecrets::get()/Secrets::set()without handling errors, so theSecrets_Exceptionreachedget_option(). The site fataled on every request, andwp plugin deactivate aifataled too. Separately,on_write()ignored a failedSecrets::set(), so a newly saved key was silently dropped.How?
All changes are in
Secrets_Bridge:get_secret()/set_secret()wrappers catch\Throwableand treat a failure as "no secret" or "not stored". Failures are logged whenWP_DEBUGis on.decrypt_all()): undecryptable secrets are skipped and left in place, so restoring the old salts can still recover them.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
npm run wp-env start, enable Key Encryption, and save an API key under Settings → Connectors.npm run wp-env:cli wp config set WP_SECRETS_KEY repro-1185Cannot decrypt master key. After: the page loads and the connector shows no key.npm run wp-env:cli wp plugin deactivate aicompletes without a fatal.npm run wp-env:cli wp config delete WP_SECRETS_KEY --skip-pluginsnpm run test:php -- --filter Key_EncryptionTest. The 4 new tests fail ondevelopand pass with this change.Changelog Entry