PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/mcp/class-mcp-pairing.php +141 -8 2.0.0 → 2.9.0 View file →
@@ -23,8 +23,10 @@
23 23 declare(strict_types=1);
24 24
25 25 namespace ThinkRank\Mcp;
26 26
27 +use ThinkRank\Core\Secret_At_Rest;
28 +
27 29 if ( ! defined( 'ABSPATH' ) ) {
28 30 exit; // Exit if accessed directly.
29 31 }
30 32
@@ -91,9 +93,9 @@
91 93
92 94 /**
93 95 * Current pairing state, defaults merged.
94 96 *
95 - * @return array{site_token:string,connected:bool,connected_at:int,scopes:string[],user_id:int,last_used:int}
97 + * @return array{site_token:string,token_hash:string,token_sealed:bool,connected:bool,connected_at:int,scopes:string[],user_id:int,last_used:int}
96 98 */
97 99 public static function state(): array {
98 100 $stored = get_option( self::OPTION, [] );
99 101 if ( ! is_array( $stored ) ) {
@@ -98,10 +100,33 @@
98 100 $stored = get_option( self::OPTION, [] );
99 101 if ( ! is_array( $stored ) ) {
100 102 $stored = [];
101 103 }
104 + $raw = isset( $stored['site_token'] ) ? (string) $stored['site_token'] : '';
105 + $plain = '' === $raw ? '' : Secret_At_Rest::decrypt( $raw );
106 +
107 + // Sealed: something IS stored, but this site can no longer open it —
108 + // the auth salt rotated, or sodium went away under us (decrypt() hands
109 + // the envelope back unchanged in that case). Either way there is no
110 + // displayable credential, and the envelope must never be passed off as
111 + // one: it would be copied into a client and 401 forever.
112 + $sealed = '' !== $raw && ( '' === $plain || Secret_At_Rest::is_encrypted( $plain ) );
113 +
102 114 return [
103 - 'site_token' => isset( $stored['site_token'] ) ? (string) $stored['site_token'] : '',
115 + // Decrypted for display and for the self-test's own probe. Stored
116 + // encrypted (#396) — a database read on its own no longer yields a
117 + // usable admin-equivalent credential.
118 + 'site_token' => $sealed ? '' : $plain,
119 + // Whether a stored token exists that cannot be shown here. Callers
120 + // use this to tell "never connected" apart from "connected, but
121 + // this site cannot display the token any more".
122 + 'token_sealed' => $sealed,
123 + // What authorize() compares against. Held separately so a token
124 + // whose ciphertext can no longer be opened — the auth salt was
125 + // rotated, the site was migrated without wp-config — keeps
126 + // authenticating the clients already configured with it, instead of
127 + // silently locking them out.
128 + 'token_hash' => isset( $stored['token_hash'] ) ? (string) $stored['token_hash'] : '',
104 129 'connected' => ! empty( $stored['connected'] ),
105 130 'connected_at' => isset( $stored['connected_at'] ) ? (int) $stored['connected_at'] : 0,
106 131 'scopes' => isset( $stored['scopes'] ) && is_array( $stored['scopes'] )
107 132 ? array_values( array_map( 'strval', $stored['scopes'] ) )
@@ -132,8 +157,78 @@
132 157 update_option( self::OPTION, $stored, false );
133 158 }
134 159
135 160 /**
161 + * SHA-256 used to store the pairing token's verifier at rest.
162 + *
163 + * Mirrors Mcp_OAuth::hash(), which has always stored access and refresh
164 + * tokens this way. The pairing token was the one exception (#396).
165 + *
166 + * @since 2.0.1
167 + *
168 + * @param string $value Raw token.
169 + * @return string
170 + */
171 + private static function hash( string $value ): string {
172 + return hash( 'sha256', $value );
173 + }
174 +
175 + /**
176 + * Whether a presented token is the pairing token.
177 + *
178 + * Compared against the stored hash. A row written before this change holds
179 + * a plaintext token and no hash, so it is verified against the plaintext
180 + * once and then upgraded in place — an existing pairing keeps working and
181 + * no one has to re-pair.
182 + *
183 + * @since 2.0.1
184 + *
185 + * @param string $presented Token presented by the client.
186 + * @return bool
187 + */
188 + public static function verify_token( string $presented ): bool {
189 + if ( '' === $presented ) {
190 + return false;
191 + }
192 +
193 + $state = self::state();
194 +
195 + if ( '' !== $state['token_hash'] ) {
196 + return hash_equals( $state['token_hash'], self::hash( $presented ) );
197 + }
198 +
199 + // Legacy row: plaintext, no hash.
200 + if ( '' === $state['site_token'] || ! hash_equals( $state['site_token'], $presented ) ) {
201 + return false;
202 + }
203 +
204 + self::upgrade_legacy_storage( $presented );
205 +
206 + return true;
207 + }
208 +
209 + /**
210 + * Re-store a legacy plaintext token encrypted, with its hash.
211 + *
212 + * @since 2.0.1
213 + *
214 + * @param string $token Raw token, already verified.
215 + * @return void
216 + */
217 + private static function upgrade_legacy_storage( string $token ): void {
218 + $stored = get_option( self::OPTION, [] );
219 +
220 + if ( ! is_array( $stored ) ) {
221 + return;
222 + }
223 +
224 + $stored['site_token'] = Secret_At_Rest::encrypt( $token );
225 + $stored['token_hash'] = self::hash( $token );
226 +
227 + update_option( self::OPTION, $stored, false );
228 + }
229 +
230 + /**
136 231 * The stored site token (secret). Empty string when not connected.
137 232 *
138 233 * @return string
139 234 */
@@ -152,13 +247,20 @@
152 247
153 248 /**
154 249 * Whether an MCP connection token is currently active for this site.
155 250 *
251 + * Deliberately reads the hash, not the decrypted token. Those are not the
252 + * same question: after an auth salt rotation the ciphertext will not open,
253 + * so `site_token` is '' — but `token_hash` still verifies the credential
254 + * every configured client is holding, and verify_token() still accepts it.
255 + * Answering "not connected" there made ensure_connected() mint a fresh
256 + * token over the hash, which was the only surviving copy of the live one.
257 + *
156 258 * @return bool
157 259 */
158 260 public static function is_connected(): bool {
159 261 $state = self::state();
160 - return $state['connected'] && '' !== $state['site_token'];
262 + return $state['connected'] && ( '' !== $state['token_hash'] || '' !== $state['site_token'] );
161 263 }
162 264
163 265 /**
164 266 * Whether the active connection is limited to read-only tools.
@@ -179,8 +281,11 @@
179 281 $state = self::state();
180 282 return [
181 283 'connected' => self::is_connected(),
182 284 'connection_token' => $state['site_token'],
285 + // Connected, but the token cannot be displayed on this site any
286 + // more. The screen offers a rotate instead of a blank recipe.
287 + 'token_sealed' => $state['token_sealed'],
183 288 'connect_url' => self::connect_url(),
184 289 'mcp_endpoint' => self::site_endpoint(),
185 290 'mcp_endpoint_rest' => self::site_endpoint_fallback(),
186 291 'connected_at' => $state['connected_at'],
@@ -293,19 +398,44 @@
293 398 * @param bool $read_only Grant only the `read` scope on a NEW token.
294 399 * @return array<string,mixed> Public status.
295 400 */
296 401 public static function connect( bool $read_only = false ): array {
297 - $state = self::state();
298 - $existing = '' !== $state['site_token'];
299 - $token = $existing ? $state['site_token'] : self::mint_token();
402 + $state = self::state();
403 + // The hash is what decides "is there a pairing", not the decrypted
404 + // token: after an auth salt rotation the ciphertext will not open, but
405 + // the credential every configured client holds still authenticates
406 + // against the hash.
407 + $existing = '' !== $state['token_hash'] || '' !== $state['site_token'];
300 408 $scopes = $existing && ! empty( $state['scopes'] )
301 409 ? $state['scopes']
302 410 : self::scopes_for( $read_only );
303 411
412 + if ( $existing && $state['token_sealed'] ) {
413 + // Keeping a pairing this site can no longer read. Falling through
414 + // would re-encrypt $state['site_token'] — which is '' here — and
415 + // write hash('') over token_hash, destroying the last copy of a
416 + // live credential and silently resetting its scopes and owner.
417 + // Touch only the metadata; rotate() is the deliberate re-mint.
418 + $stored = get_option( self::OPTION, [] );
419 + $stored = is_array( $stored ) ? $stored : [];
420 + $stored['connected'] = true;
421 + $stored['scopes'] = $scopes;
422 + if ( empty( $stored['user_id'] ) ) {
423 + $stored['user_id'] = get_current_user_id();
424 + }
425 +
426 + update_option( self::OPTION, $stored, false );
427 +
428 + return self::public_status();
429 + }
430 +
431 + $token = $existing ? $state['site_token'] : self::mint_token();
432 +
304 433 update_option(
305 434 self::OPTION,
306 435 [
307 - 'site_token' => $token,
436 + 'site_token' => Secret_At_Rest::encrypt( $token ),
437 + 'token_hash' => self::hash( $token ),
308 438 'connected' => true,
309 439 'connected_at' => $existing ? $state['connected_at'] : time(),
310 440 'scopes' => $scopes,
311 441 'user_id' => $existing && $state['user_id'] ? $state['user_id'] : get_current_user_id(),
@@ -328,12 +458,15 @@
328 458 $scopes = null === $read_only
329 459 ? ( ! empty( $state['scopes'] ) ? $state['scopes'] : self::DEFAULT_SCOPES )
330 460 : self::scopes_for( $read_only );
331 461
462 + $token = self::mint_token();
463 +
332 464 update_option(
333 465 self::OPTION,
334 466 [
335 - 'site_token' => self::mint_token(),
467 + 'site_token' => Secret_At_Rest::encrypt( $token ),
468 + 'token_hash' => self::hash( $token ),
336 469 'connected' => true,
337 470 'connected_at' => time(),
338 471 'scopes' => $scopes,
339 472 'user_id' => get_current_user_id() ? get_current_user_id() : $state['user_id'],