From adc0f757bced0011f118c59484c468f43b7a5a04 Mon Sep 17 00:00:00 2001 From: "Matthew M. Emma" Date: Sat, 8 Aug 2026 08:20:13 -0700 Subject: [PATCH] Repair client secrets damaged by 2.0.0 and 2.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.0.2 stopped the settings sanitizer adding an encryption layer on every write, but it did nothing for sites that had already been damaged. Those installs hold cipher text a single decrypt cannot recover, so they fail to authenticate with nothing on screen to explain why — the operator would have to guess that the stored credential itself is the problem. The upgrade routine now unwraps the value back to plain text and re-stores it correctly. Encryption::decrypt_deep() peels layers with a bounded ceiling, and Encryption::depth() reports how many are present. If the value cannot be recovered it is cleared rather than left broken, and either outcome is reported in an admin notice that appears once. The stored token is also dropped so the next request re-authenticates with the repaired secret instead of a stale one. Verified against a site running the real v2.0.1 release, corrupted through 2.0.1's own code path rather than by hand-crafting the value: BEFORE version=2.0.1 depth=3 client_secret()='pwp2:Tnd7t4TV3YLRFwvRF' AFTER version=2.0.3 depth=1 client_secret()='THE-REAL-SECRET' Four unit tests cover the repair, the two no-op cases and the unrecoverable path. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 19 +++++++++++ languages/procore-connect.pot | 27 ++++++++++----- procore-connect.php | 4 +-- readme.txt | 9 ++++- src/Admin/Notices.php | 35 +++++++++++++++++++ src/Admin/Settings.php | 42 +++++++++++++++++++++++ src/Plugin.php | 16 +++++++++ src/Support/Encryption.php | 64 +++++++++++++++++++++++++++++++++++ tests/unit/SettingsTest.php | 62 +++++++++++++++++++++++++++++++++ 9 files changed, 266 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c50f38..10b6aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to Procore Connect are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.3] — 2026-08-08 + +### Fixed + +- **Repairs a client secret damaged by 2.0.0 or 2.0.1.** Those versions added an + encryption layer to the stored secret on every settings write, so an install + upgrading from either can hold cipher text that a single decrypt cannot recover — + the site simply fails to authenticate, with nothing to explain why. 2.0.2 stopped + the damage but did not undo it. + + The upgrade routine now unwraps the value back to plain text and re-stores it + correctly, and reports what it did in an admin notice. If the value cannot be + recovered it is cleared and the administrator is asked to enter it again, rather + than being left with a silent authentication failure. + + Added `Encryption::decrypt_deep()` and `Encryption::depth()`, plus four tests that + reproduce a three-layer secret and assert it is restored. + ## [2.0.2] — 2026-08-08 ### Fixed @@ -188,6 +206,7 @@ every install was non-functional regardless of configuration. - Initial release. +[2.0.3]: https://github.com/ibuilder/ProcoreWP/releases/tag/v2.0.3 [2.0.2]: https://github.com/ibuilder/ProcoreWP/releases/tag/v2.0.2 [2.0.1]: https://github.com/ibuilder/ProcoreWP/releases/tag/v2.0.1 [2.0.0]: https://github.com/ibuilder/ProcoreWP/releases/tag/v2.0.0 diff --git a/languages/procore-connect.pot b/languages/procore-connect.pot index 08a729c..5e76191 100644 --- a/languages/procore-connect.pot +++ b/languages/procore-connect.pot @@ -253,7 +253,7 @@ msgstr "" msgid "Connected as %1$s (%2$s)." msgstr "" -#: src/Admin/Notices.php:68 +#: src/Admin/Notices.php:69 msgid "Connected to Procore." msgstr "" @@ -430,7 +430,7 @@ msgstr "" msgid "Encrypted (AES-256-GCM)" msgstr "" -#: src/Admin/Notices.php:77 +#: src/Admin/Notices.php:78 msgid "Enter a Client ID and Client Secret before connecting." msgstr "" @@ -663,7 +663,7 @@ msgstr "" msgid "Open — resumes in %s" msgstr "" -#: src/Admin/Notices.php:123 +#: src/Admin/Notices.php:158 msgid "OpenSSL is not available on this server, so your Procore Client Secret cannot be encrypted in the database. Define PROCORE_CONNECT_CLIENT_SECRET in wp-config.php instead." msgstr "" @@ -724,15 +724,24 @@ msgid "Procore Connect (visible to administrators only): %s" msgstr "" #. translators: %s: settings screen URL. -#: src/Admin/Notices.php:102 +#: src/Admin/Notices.php:139 +msgid "Procore Connect could not recover your stored Client Secret. Versions 2.0.0 and 2.0.1 re-encrypted it on every settings save until it became unreadable, so it has been cleared. Please enter it again; this cannot happen on 2.0.2 or later." +msgstr "" + +#. translators: %s: settings screen URL. +#: src/Admin/Notices.php:103 msgid "Procore Connect imported your settings from version 1.x. Version 1.x authenticated against the wrong Procore host, so you need to re-run the connection test before shortcodes will return data." msgstr "" #. translators: 1: environment label, 2: API host. -#: src/Admin/Notices.php:141 +#: src/Admin/Notices.php:176 msgid "Procore Connect is pointed at %1$s (%2$s). Front-end shortcodes are showing sandbox data." msgstr "" +#: src/Admin/Notices.php:129 +msgid "Procore Connect repaired your stored Client Secret. Versions 2.0.0 and 2.0.1 re-encrypted it on every settings save, which eventually made it unreadable. Nothing further is needed, though it is worth running Procore → Connection → Test connection to confirm." +msgstr "" + #: src/Api/Auth/AbstractAuth.php:101 msgid "Procore did not return an access token." msgstr "" @@ -766,7 +775,7 @@ msgid "Procore rejected the authentication request (HTTP %d)." msgstr "" #. translators: %s: human readable time difference. -#: src/Admin/Notices.php:164 +#: src/Admin/Notices.php:199 msgid "Procore requests are paused after repeated failures and will resume in %s. Cached data is being served in the meantime." msgstr "" @@ -998,7 +1007,7 @@ msgstr "" msgid "Service Account (Client Credentials) — recommended" msgstr "" -#: src/Plugin.php:158 +#: src/Plugin.php:174 msgid "Settings" msgstr "" @@ -1099,7 +1108,7 @@ msgstr "" msgid "The Procore Connect REST proxy is disabled." msgstr "" -#: src/Admin/Notices.php:76 +#: src/Admin/Notices.php:77 msgid "The Procore authorization request was declined." msgstr "" @@ -1107,7 +1116,7 @@ msgstr "" msgid "The Procore authorization response could not be verified. Please start the connection again." msgstr "" -#: src/Admin/Notices.php:78 +#: src/Admin/Notices.php:79 msgid "The Procore connection could not be completed." msgstr "" diff --git a/procore-connect.php b/procore-connect.php index b1a163c..5de4d46 100644 --- a/procore-connect.php +++ b/procore-connect.php @@ -3,7 +3,7 @@ * Plugin Name: Procore Connect * Plugin URI: https://github.com/ibuilder/ProcoreWP * Description: Connect WordPress to the Procore construction management platform. Display projects, teams, drawings, RFIs and more with shortcodes, blocks and a cached REST proxy. - * Version: 2.0.2 + * Version: 2.0.3 * Requires at least: 6.5 * Requires PHP: 7.4 * Author: ibuilder @@ -22,7 +22,7 @@ defined( 'ABSPATH' ) || exit; -const VERSION = '2.0.2'; +const VERSION = '2.0.3'; define( 'PROCORE_CONNECT_VERSION', VERSION ); define( 'PROCORE_CONNECT_FILE', __FILE__ ); diff --git a/readme.txt b/readme.txt index 7a8eafb..6f3839d 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: procore, construction, project management, shortcode, api Requires at least: 6.5 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 2.0.2 +Stable tag: 2.0.3 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -127,6 +127,10 @@ Yes. Caching goes through the transient API, so a persistent object cache such a == Changelog == += 2.0.3 = + +* **Repairs credentials damaged by 2.0.0 or 2.0.1.** Those versions re-encrypted the stored Client Secret on every settings save until it could no longer be read, leaving the site unable to authenticate with no explanation. 2.0.2 stopped the damage; 2.0.3 undoes it. The upgrade unwraps the value, re-stores it correctly, and tells you what it did. If it cannot be recovered it is cleared and you are asked to enter it again. + = 2.0.2 = * **Fixed a credential-corrupting bug.** WordPress runs a registered setting's sanitize callback on every update to that option, including the plugin's own internal writes. Because the sanitizer encrypted the client secret unconditionally, each write re-encrypted the stored value until it could no longer be decrypted and the site silently lost its Procore connection. Encryption is now idempotent. If your connection stopped working after saving settings, re-enter the Client Secret once on 2.0.2 and it will stay valid. @@ -174,6 +178,9 @@ A complete rewrite. See the upgrade notice below before updating. == Upgrade Notice == += 2.0.3 = +Repairs a Client Secret damaged by 2.0.0 or 2.0.1, which re-encrypted it on every save until it stopped working. Upgrading restores it automatically and reports the result. Recommended for anyone who ran 2.0.0 or 2.0.1. + = 2.0.2 = Important fix: repeated settings saves could re-encrypt the stored Client Secret until it became unrecoverable, silently breaking the Procore connection. Update, then re-enter your Client Secret once if the connection had stopped working. diff --git a/src/Admin/Notices.php b/src/Admin/Notices.php index e2b3180..b55ee93 100644 --- a/src/Admin/Notices.php +++ b/src/Admin/Notices.php @@ -41,6 +41,7 @@ public function render(): void { $this->oauth_result(); $this->migration_notice(); + $this->secret_repair_notice(); if ( ! $this->on_plugin_screen() ) { return; @@ -108,6 +109,40 @@ private function migration_notice(): void { delete_option( 'procore_connect_migrated_from' ); } + /** + * Report the outcome of repairing a secret damaged by 2.0.0 or 2.0.1. + * + * @return void + */ + private function secret_repair_notice(): void { + $state = (string) get_option( 'procore_connect_secret_repair', '' ); + + if ( '' === $state ) { + return; + } + + delete_option( 'procore_connect_secret_repair' ); + + if ( 'repaired' === $state ) { + $this->notice( + 'success', + __( 'Procore Connect repaired your stored Client Secret. Versions 2.0.0 and 2.0.1 re-encrypted it on every settings save, which eventually made it unreadable. Nothing further is needed, though it is worth running Procore → Connection → Test connection to confirm.', 'procore-connect' ) + ); + + return; + } + + $this->notice( + 'error', + sprintf( + /* translators: %s: settings screen URL. */ + __( 'Procore Connect could not recover your stored Client Secret. Versions 2.0.0 and 2.0.1 re-encrypted it on every settings save until it became unreadable, so it has been cleared. Please enter it again; this cannot happen on 2.0.2 or later.', 'procore-connect' ), + esc_url( admin_url( 'admin.php?page=procore-connect' ) ) + ), + true + ); + } + /** * Warn when credentials cannot be encrypted at rest. * diff --git a/src/Admin/Settings.php b/src/Admin/Settings.php index 7c47e55..28c48b6 100644 --- a/src/Admin/Settings.php +++ b/src/Admin/Settings.php @@ -281,6 +281,48 @@ public static function sanitize( $input ): array { return (array) apply_filters( 'procore_connect_sanitize_settings', $clean, $input ); } + /** + * Repair a client secret that earlier versions encrypted more than once. + * + * Versions 2.0.0 and 2.0.1 ran the settings sanitizer on every write to the + * option, including internal ones, so the stored secret gained a layer of + * encryption each time. Those installs cannot authenticate and give no clue + * why. Peel the value back to plain text and re-store it correctly. + * + * @return string One of `ok` (nothing to do), `repaired`, or `unrecoverable`. + */ + public static function repair_client_secret(): string { + $stored = (string) self::get( 'client_secret', '' ); + + if ( '' === $stored ) { + return 'ok'; + } + + if ( Encryption::depth( $stored ) <= 1 ) { + return 'ok'; + } + + $plain = Encryption::decrypt_deep( $stored ); + + if ( '' === $plain ) { + // Nothing usable is left; clear it so the admin is prompted rather + // than left staring at an unexplained authentication failure. + $all = self::all(); + $all['client_secret'] = ''; + update_option( self::OPTION, $all, false ); + self::flush(); + + return 'unrecoverable'; + } + + $all = self::all(); + $all['client_secret'] = Encryption::encrypt( $plain ); + update_option( self::OPTION, $all, false ); + self::flush(); + + return 'repaired'; + } + /** * Import settings saved by ProcoreWP 1.x. * diff --git a/src/Plugin.php b/src/Plugin.php index 6e2731c..0df29cb 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -15,6 +15,7 @@ use ProcoreConnect\Admin\SettingsPage; use ProcoreConnect\Admin\Settings; use ProcoreConnect\Api\Cache; +use ProcoreConnect\Api\TokenStore; use ProcoreConnect\Blocks\Registrar as BlockRegistrar; use ProcoreConnect\Cli\Commands; use ProcoreConnect\Frontend\Assets; @@ -120,6 +121,21 @@ public function maybe_upgrade(): void { Cache::flush(); } + /* + * 2.0.0 and 2.0.1 re-encrypted the client secret on every settings + * write, so an install upgrading from either may hold a secret it can + * no longer decrypt. Repair it here rather than leaving the site with + * an unexplained authentication failure. + */ + if ( '' !== $installed && version_compare( $installed, '2.0.2', '<' ) ) { + $repair = Settings::repair_client_secret(); + + if ( 'ok' !== $repair ) { + update_option( 'procore_connect_secret_repair', $repair, false ); + TokenStore::clear(); + } + } + update_option( self::VERSION_OPTION, PROCORE_CONNECT_VERSION, false ); } diff --git a/src/Support/Encryption.php b/src/Support/Encryption.php index d0b99ac..87bbe0b 100644 --- a/src/Support/Encryption.php +++ b/src/Support/Encryption.php @@ -163,6 +163,70 @@ public static function decrypt( string $value ): string { return false === $plain ? '' : $plain; } + /** + * Peel every layer of encryption off a value. + * + * Versions 2.0.0 and 2.0.1 re-encrypted the stored client secret on each + * settings write, leaving multi-layered cipher text that a single + * `decrypt()` cannot recover. This unwraps repeatedly until the result is + * no longer cipher text, so an affected install can be repaired in place + * rather than forcing the operator to find their credentials again. + * + * @param string $value Stored value, possibly encrypted more than once. + * @param int $layers Safety ceiling on unwrapping passes. + * @return string Plain text value, or an empty string when unrecoverable. + */ + public static function decrypt_deep( string $value, int $layers = 12 ): string { + $current = $value; + + for ( $i = 0; $i < $layers; $i++ ) { + if ( ! self::is_encrypted( $current ) ) { + return $current; + } + + $next = self::decrypt( $current ); + + // A layer that will not unwrap means the value is unrecoverable. + if ( '' === $next || $next === $current ) { + return ''; + } + + $current = $next; + } + + // Still cipher text after the ceiling: treat as unrecoverable. + return self::is_encrypted( $current ) ? '' : $current; + } + + /** + * How many times a value has been encrypted. + * + * @param string $value Stored value. + * @param int $layers Safety ceiling on counting passes. + * @return int Number of encryption layers; 0 for plain text. + */ + public static function depth( string $value, int $layers = 12 ): int { + $current = $value; + $depth = 0; + + for ( $i = 0; $i < $layers; $i++ ) { + if ( ! self::is_encrypted( $current ) ) { + break; + } + + $next = self::decrypt( $current ); + + if ( '' === $next || $next === $current ) { + return $depth + 1; + } + + $current = $next; + ++$depth; + } + + return $depth; + } + /** * Mask a secret for display, revealing only the final four characters. * diff --git a/tests/unit/SettingsTest.php b/tests/unit/SettingsTest.php index c6c0434..2e7d853 100644 --- a/tests/unit/SettingsTest.php +++ b/tests/unit/SettingsTest.php @@ -180,6 +180,68 @@ public function test_email_suppression_defaults_to_on(): void { $this->assertTrue( Settings::defaults()['suppress_emails'] ); } + /** + * A secret mangled by 2.0.0 or 2.0.1 must be repaired in place. + * + * Those versions added an encryption layer on every settings write, so an + * upgrading install can hold cipher text a single decrypt cannot recover. + * + * @return void + */ + public function test_repairs_a_multi_encrypted_secret(): void { + // Reproduce the damage: three layers, as a real affected install would have. + $mangled = Encryption::encrypt( Encryption::encrypt( Encryption::encrypt( 'the-real-secret' ) ) ); + + update_option( Settings::OPTION, array( 'client_secret' => $mangled ) ); + Settings::flush(); + + $this->assertSame( 3, Encryption::depth( $mangled ) ); + $this->assertNotSame( 'the-real-secret', Settings::client_secret(), 'precondition: the secret is unusable' ); + + $this->assertSame( 'repaired', Settings::repair_client_secret() ); + $this->assertSame( 'the-real-secret', Settings::client_secret() ); + $this->assertSame( 1, Encryption::depth( (string) Settings::get( 'client_secret' ) ) ); + } + + /** + * A correctly stored secret must be left alone by the repair. + * + * @return void + */ + public function test_repair_is_a_no_op_on_a_healthy_secret(): void { + update_option( Settings::OPTION, array( 'client_secret' => Encryption::encrypt( 'fine' ) ) ); + Settings::flush(); + + $this->assertSame( 'ok', Settings::repair_client_secret() ); + $this->assertSame( 'fine', Settings::client_secret() ); + } + + /** + * An empty secret must not be treated as damaged. + * + * @return void + */ + public function test_repair_is_a_no_op_when_no_secret_is_stored(): void { + update_option( Settings::OPTION, array( 'client_secret' => '' ) ); + Settings::flush(); + + $this->assertSame( 'ok', Settings::repair_client_secret() ); + } + + /** + * An unrecoverable secret must be cleared so the admin is prompted. + * + * @return void + */ + public function test_repair_clears_an_unrecoverable_secret(): void { + // Cipher text whose inner layer is not recoverable. + update_option( Settings::OPTION, array( 'client_secret' => Encryption::encrypt( 'pwp2:not-valid-base64-cipher' ) ) ); + Settings::flush(); + + $this->assertSame( 'unrecoverable', Settings::repair_client_secret() ); + $this->assertSame( '', Settings::client_secret() ); + } + /** * Settings written by ProcoreWP 1.x must be imported and re-encrypted. *