| @@ -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'], |