| @@ -93,9 +93,9 @@ | ||
| 93 | 93 | |
| 94 | 94 | /** |
| 95 | 95 | * Current pairing state, defaults merged. |
| 96 | 96 | * |
| 97 | - * @return array{site_token:string,token_hash: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} | |
| 98 | 98 | */ |
| 99 | 99 | public static function state(): array { |
| 100 | 100 | $stored = get_option( self::OPTION, [] ); |
| 101 | 101 | if ( ! is_array( $stored ) ) { |
| @@ -100,15 +100,27 @@ | ||
| 100 | 100 | $stored = get_option( self::OPTION, [] ); |
| 101 | 101 | if ( ! is_array( $stored ) ) { |
| 102 | 102 | $stored = []; |
| 103 | 103 | } |
| 104 | - $raw = isset( $stored['site_token'] ) ? (string) $stored['site_token'] : ''; | |
| 104 | + $raw = isset( $stored['site_token'] ) ? (string) $stored['site_token'] : ''; | |
| 105 | + $plain = '' === $raw ? '' : Secret_At_Rest::decrypt( $raw ); | |
| 105 | 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 | + | |
| 106 | 114 | return [ |
| 107 | 115 | // Decrypted for display and for the self-test's own probe. Stored |
| 108 | 116 | // encrypted (#396) — a database read on its own no longer yields a |
| 109 | 117 | // usable admin-equivalent credential. |
| 110 | - 'site_token' => '' === $raw ? '' : Secret_At_Rest::decrypt( $raw ), | |
| 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, | |
| 111 | 123 | // What authorize() compares against. Held separately so a token |
| 112 | 124 | // whose ciphertext can no longer be opened — the auth salt was |
| 113 | 125 | // rotated, the site was migrated without wp-config — keeps |
| 114 | 126 | // authenticating the clients already configured with it, instead of |
| @@ -235,13 +247,20 @@ | ||
| 235 | 247 | |
| 236 | 248 | /** |
| 237 | 249 | * Whether an MCP connection token is currently active for this site. |
| 238 | 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 | + * | |
| 239 | 258 | * @return bool |
| 240 | 259 | */ |
| 241 | 260 | public static function is_connected(): bool { |
| 242 | 261 | $state = self::state(); |
| 243 | - return $state['connected'] && '' !== $state['site_token']; | |
| 262 | + return $state['connected'] && ( '' !== $state['token_hash'] || '' !== $state['site_token'] ); | |
| 244 | 263 | } |
| 245 | 264 | |
| 246 | 265 | /** |
| 247 | 266 | * Whether the active connection is limited to read-only tools. |
| @@ -262,8 +281,11 @@ | ||
| 262 | 281 | $state = self::state(); |
| 263 | 282 | return [ |
| 264 | 283 | 'connected' => self::is_connected(), |
| 265 | 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'], | |
| 266 | 288 | 'connect_url' => self::connect_url(), |
| 267 | 289 | 'mcp_endpoint' => self::site_endpoint(), |
| 268 | 290 | 'mcp_endpoint_rest' => self::site_endpoint_fallback(), |
| 269 | 291 | 'connected_at' => $state['connected_at'], |
| @@ -376,14 +398,38 @@ | ||
| 376 | 398 | * @param bool $read_only Grant only the `read` scope on a NEW token. |
| 377 | 399 | * @return array<string,mixed> Public status. |
| 378 | 400 | */ |
| 379 | 401 | public static function connect( bool $read_only = false ): array { |
| 380 | - $state = self::state(); | |
| 381 | - $existing = '' !== $state['site_token']; | |
| 382 | - $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']; | |
| 383 | 408 | $scopes = $existing && ! empty( $state['scopes'] ) |
| 384 | 409 | ? $state['scopes'] |
| 385 | 410 | : self::scopes_for( $read_only ); |
| 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(); | |
| 386 | 432 | |
| 387 | 433 | update_option( |
| 388 | 434 | self::OPTION, |
| 389 | 435 | [ |