From 6ee71f01ed54bc814547edc0eead241f8a2e5d44 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Tue, 6 Oct 2026 15:46:25 +0200 Subject: [PATCH 01/11] Update reprint-server to 0.10.13 Co-Authored-By: Claude Opus 5.5 --- bin/check-autoload-manifest.php | 11 +++++++++++ plugin/composer.json | 2 +- plugin/composer.lock | 13 +++++++------ tests/e2e/request.php | 8 +++++--- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/bin/check-autoload-manifest.php b/bin/check-autoload-manifest.php index 17cdfabd..e89fef84 100755 --- a/bin/check-autoload-manifest.php +++ b/bin/check-autoload-manifest.php @@ -17,22 +17,33 @@ $wpcom_migration_expected_classes = array( 'Site_Export_HMAC_Client', + 'WordPress\\Reprint\\Server\\DatabasePush', + 'WordPress\\Reprint\\Server\\DatabasePushEndpoints', 'WordPress\\Reprint\\Server\\DatabaseRowsReader', + 'WordPress\\Reprint\\Server\\EnvelopeSigner', 'WordPress\\Reprint\\Server\\FileIndexProcessor', 'WordPress\\Reprint\\Server\\FileTreeProducer', 'WordPress\\Reprint\\Server\\GzipOutputStream', 'WordPress\\Reprint\\Server\\HMACServer', 'WordPress\\Reprint\\Server\\HTTPServer', 'WordPress\\Reprint\\Server\\MultipartProcessor', + 'WordPress\\Reprint\\Server\\MultisiteDatabaseSelection', + 'WordPress\\Reprint\\Server\\MultisiteFileSelection', 'WordPress\\Reprint\\Server\\MySQLDumpProducer', + 'WordPress\\Reprint\\Server\\MysqliDriverPDO', + 'WordPress\\Reprint\\Server\\MysqliDriverPDOStatement', 'WordPress\\Reprint\\Server\\PdoConstants', + 'WordPress\\Reprint\\Server\\PublicKeyClient', + 'WordPress\\Reprint\\Server\\PublicKeyServer', 'WordPress\\Reprint\\Server\\PushConfigurationException', 'WordPress\\Reprint\\Server\\PushEndpoints', 'WordPress\\Reprint\\Server\\PushException', 'WordPress\\Reprint\\Server\\PushSession', + 'WordPress\\Reprint\\Server\\RequestAuthenticator', 'WordPress\\Reprint\\Server\\ResourceBudget', 'WordPress\\Reprint\\Server\\SqliteDriverPDO', 'WordPress\\Reprint\\Server\\SqliteDriverPDOStatement', + 'WordPress\\Reprint\\Server\\Utils', 'WordPress\\Reprint\\Server\\WpdbDriverPDO', 'WordPress\\Reprint\\Server\\WpdbDriverPDOStatement', ); diff --git a/plugin/composer.json b/plugin/composer.json index 400531c0..28845f56 100644 --- a/plugin/composer.json +++ b/plugin/composer.json @@ -8,7 +8,7 @@ "automattic/jetpack-autoloader": "^6", "automattic/jetpack-config": "^4.0", "automattic/jetpack-connection": "^9.1", - "wp-php-toolkit/reprint-server": "0.10.8" + "wp-php-toolkit/reprint-server": "0.10.13" }, "autoload": { "classmap": [ diff --git a/plugin/composer.lock b/plugin/composer.lock index f6b45438..dd243a08 100644 --- a/plugin/composer.lock +++ b/plugin/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b448c13effa1185504f74293123b4062", + "content-hash": "4b89aa46ef6e3fecde290380e8e01762", "packages": [ { "name": "automattic/jetpack-a8c-mc-stats", @@ -650,16 +650,16 @@ }, { "name": "wp-php-toolkit/reprint-server", - "version": "v0.10.8", + "version": "v0.10.13", "source": { "type": "git", "url": "https://github.com/wp-php-toolkit/reprint-server", - "reference": "5d8f0cee93e1cf840f51e18060d9d7512eb70c83" + "reference": "2b298f2af6d15bc34d51ad4bc965d4a4c51765ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-php-toolkit/reprint-server/zipball/5d8f0cee93e1cf840f51e18060d9d7512eb70c83", - "reference": "5d8f0cee93e1cf840f51e18060d9d7512eb70c83", + "url": "https://api.github.com/repos/wp-php-toolkit/reprint-server/zipball/2b298f2af6d15bc34d51ad4bc965d4a4c51765ea", + "reference": "2b298f2af6d15bc34d51ad4bc965d4a4c51765ea", "shasum": "" }, "require": { @@ -669,6 +669,7 @@ "wp-php-toolkit/reprint-exporter": "self.version" }, "suggest": { + "ext-openssl": "Needed to verify public-key signatures; hosts without it accept HMAC connection tokens only.", "ext-pdo": "Needed for the SQLite export path; recommended for MySQL exports (the wpdb fallback handles MySQL when absent).", "ext-pdo_mysql": "Recommended for MySQL exports; falls back to wpdb when absent." }, @@ -683,7 +684,7 @@ "GPL-2.0-or-later" ], "description": "Streaming export engine used by the Reprint Server WordPress plugin", - "time": "2026-09-06T14:45:25+00:00" + "time": "2026-10-01T13:01:25+00:00" } ], "packages-dev": [], diff --git a/tests/e2e/request.php b/tests/e2e/request.php index b4f18d9f..fb38f25d 100644 --- a/tests/e2e/request.php +++ b/tests/e2e/request.php @@ -22,11 +22,13 @@ $wpcom_migration_secret = 'smoke-secret-0123456789abcdef0123456789abcdef'; $wpcom_migration_endpoint = $wpcom_migration_base_url . '/?reprint-api-wpcom-migration&endpoint=preflight'; -$wpcom_migration_client_file = $wpcom_migration_plugin_dir . '/vendor/wp-php-toolkit/reprint-server/src/class-hmac-client.php'; -if ( ! is_file( $wpcom_migration_client_file ) ) { +$wpcom_migration_server_src = $wpcom_migration_plugin_dir . '/vendor/wp-php-toolkit/reprint-server/src/'; +if ( ! is_file( $wpcom_migration_server_src . 'class-hmac-client.php' ) ) { wpcom_migration_e2e_fail( 'Not a built plugin tree (no HMAC client): ' . $wpcom_migration_plugin_dir ); } -require_once $wpcom_migration_client_file; +foreach ( array( 'class-envelope-signer.php', 'class-utils.php', 'class-hmac-client.php', 'class-public-key-client.php' ) as $wpcom_migration_server_file ) { + require_once $wpcom_migration_server_src . $wpcom_migration_server_file; +} require_once __DIR__ . '/provisioning-steps.php'; switch ( $wpcom_migration_scenario ) { From d7dfba405a77b0ceaed617478f9f134d59a18adc Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Tue, 6 Oct 2026 15:49:59 +0200 Subject: [PATCH 02/11] Let WordPress.com install a public key for Reprint exports Exports are now verified by Reprint's RequestAuthenticator, which accepts the installed key or the shared secret, whichever the host supports. Co-Authored-By: Claude Opus 5.5 --- plugin/reprint/class-exporter.php | 117 ++++++++++++---- plugin/reprint/class-rest-controller.php | 93 ++++++++++++- tests/e2e/blueprint-provisioning-key.json | 22 +++ tests/e2e/request.php | 156 ++++++++++++++++++---- tests/e2e/run.sh | 7 +- 5 files changed, 334 insertions(+), 61 deletions(-) create mode 100644 tests/e2e/blueprint-provisioning-key.json diff --git a/plugin/reprint/class-exporter.php b/plugin/reprint/class-exporter.php index 536d19cf..c3cd03a3 100644 --- a/plugin/reprint/class-exporter.php +++ b/plugin/reprint/class-exporter.php @@ -1,6 +1,6 @@ 0 - && $enabled_at <= $now + self::HMAC_CLOCK_SKEW + && $enabled_at <= $now + self::CLOCK_SKEW && ( $now - $enabled_at ) <= HOUR_IN_SECONDS && self::credential_hash_matches( self::ENABLED_HASH_OPTION, $enabled_at ); } @@ -318,23 +359,35 @@ public static function close_export_window() { } /** - * The state the settings screen renders. + * The state the screens render. * * @return array { * @type bool $has_secret Whether a secret is stored. * @type bool $secret_valid Whether its hash matches under the current salt. + * @type bool $has_public_key Whether a public key is stored. + * @type bool $public_key_valid Whether its hash matches under the current salt. + * @type bool $has_credential Whether either is stored. + * @type bool $credential_valid Whether either is valid. * @type bool $window_open Whether the export window is open. * @type int|null $window_expires_at Unix time the window lapses, or null when closed. * } */ public static function get_state() { - $secret = get_option( self::SECRET_OPTION, '' ); - $has_secret = is_string( $secret ) && '' !== $secret; - $window_open = self::is_export_window_open(); + $secret = get_option( self::SECRET_OPTION, '' ); + $has_secret = is_string( $secret ) && '' !== $secret; + $secret_valid = $has_secret && self::credential_hash_matches( self::SECRET_HASH_OPTION, $secret ); + $public_key = get_option( self::PUBLIC_KEY_OPTION, '' ); + $has_public_key = is_string( $public_key ) && '' !== $public_key; + $public_key_valid = $has_public_key && self::credential_hash_matches( self::PUBLIC_KEY_HASH_OPTION, $public_key ); + $window_open = self::is_export_window_open(); return array( 'has_secret' => $has_secret, - 'secret_valid' => $has_secret && self::credential_hash_matches( self::SECRET_HASH_OPTION, $secret ), + 'secret_valid' => $secret_valid, + 'has_public_key' => $has_public_key, + 'public_key_valid' => $public_key_valid, + 'has_credential' => $has_secret || $has_public_key, + 'credential_valid' => $secret_valid || $public_key_valid, 'window_open' => $window_open, 'window_expires_at' => $window_open ? (int) get_option( self::ENABLED_OPTION, 0 ) + HOUR_IN_SECONDS : null, ); @@ -359,8 +412,8 @@ public function handle_request() { // Any origin: the client may run in a browser (Playground) from // deployments we cannot know ahead of time, and origin is no boundary - // when every request needs the HMAC secret anyway. Preflights come - // before HMAC because browsers send them without credentials, and + // when every request must be signed anyway. Preflights come before + // the signature check because browsers send them without credentials, and // before the window check so a client whose window has closed can // reach the 409 below. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash @@ -376,29 +429,37 @@ public function handle_request() { // Without a valid signature a closed window answers nothing, so an idle // site stays indistinguishable from one that never had the feature. - $window_open = self::is_export_window_open(); + $state = self::get_state(); + $window_open = $state['window_open']; - $secret = get_option( self::SECRET_OPTION, '' ); - if ( ! is_string( $secret ) || '' === $secret ) { + if ( ! $state['has_credential'] ) { if ( ! $window_open ) { return; } - $this->error( 503, 'Export not configured. Save a shared secret on the Migrate to WordPress.com Reprint migration screen.' ); + $this->error( 503, 'Export not configured. Start the migration on WordPress.com.' ); return; } - // A secret this class did not hash under the current salt is no + // A credential this class did not hash under the current salt is no // credential at all, so it never reaches signature verification. - if ( ! self::credential_hash_matches( self::SECRET_HASH_OPTION, $secret ) ) { + if ( ! $state['credential_valid'] ) { if ( ! $window_open ) { return; } self::record_event( 'credential_hash_mismatch' ); - $this->error( 503, 'Export credential invalidated: the stored secret does not match this site\'s salts. Save a new secret on the settings screen.' ); + $this->error( 503, 'Export credential invalidated: the stored credential does not match this site\'s salts. Start the migration again on WordPress.com.' ); return; } - $auth_error = $this->verify_hmac( $secret ); + $secret = $state['secret_valid'] ? get_option( self::SECRET_OPTION ) : null; + $public_keys = array(); + if ( $state['public_key_valid'] ) { + $public_key = get_option( self::PUBLIC_KEY_OPTION ); + + $public_keys[ Utils::public_key_fingerprint( $public_key ) ] = $public_key; + } + + $auth_error = $this->verify_signature( $secret, $public_keys ); if ( null !== $auth_error ) { if ( ! $window_open ) { return; @@ -447,16 +508,18 @@ protected function requested_endpoint() { } /** - * Verifies the HMAC signature of the current request. + * Verifies the current request's signature against the stored secret or + * public key. Reprint decides which scheme this host accepts. * - * Seam so a test double can skip the real server. + * Seam so a test double can skip the real authenticator. * - * @param string $secret The shared secret. + * @param string|null $secret The valid shared secret, or null. + * @param array $public_keys Valid public keys by key id. * @return string|null Error message on failure, null on success. */ - protected function verify_hmac( $secret ) { - $hmac_server = new HMACServer( $secret, self::HMAC_CLOCK_SKEW ); - return $hmac_server->verify_globals(); + protected function verify_signature( $secret, array $public_keys ) { + $authenticator = new RequestAuthenticator( $secret, $public_keys, self::CLOCK_SKEW ); + return $authenticator->verify_globals(); } /** diff --git a/plugin/reprint/class-rest-controller.php b/plugin/reprint/class-rest-controller.php index 18f317cd..c085524c 100644 --- a/plugin/reprint/class-rest-controller.php +++ b/plugin/reprint/class-rest-controller.php @@ -12,13 +12,18 @@ namespace Automattic\WPCOM_Migration\Reprint; use Automattic\Jetpack\Connection\Rest_Authentication; +use InvalidArgumentException; +use WordPress\Reprint\Server\PublicKeyServer; +use WordPress\Reprint\Server\Utils; use WP_Error; use WP_REST_Controller; +use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; /** - * POST wpcom-migration/v1/reprint/rotate-export-secret and enable-export. + * POST wpcom-migration/v1/reprint/rotate-export-secret, install-public-key and + * enable-export. */ class REST_Controller extends WP_REST_Controller { @@ -37,7 +42,7 @@ class REST_Controller extends WP_REST_Controller { protected $rest_base = 'reprint'; /** - * Registers both routes. Always registered: the permission check refuses + * Registers the routes. Always registered: the permission check refuses * anyone else, and a 404 would read as "plugin absent". */ public function register_routes() { @@ -53,6 +58,25 @@ public function register_routes() { ) ); + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/install-public-key', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'install_public_key' ), + 'permission_callback' => array( $this, 'permission_check' ), + 'args' => array( + 'public_key' => array( + 'description' => __( 'RSA public key, as PEM or one line of base64.', 'wpcom-migration' ), + 'type' => 'string', + 'required' => true, + ), + ), + ), + ) + ); + register_rest_route( $this->namespace, '/' . $this->rest_base . '/enable-export', @@ -107,18 +131,73 @@ public function rotate_secret() { ); } + /** + * Replaces the enrolled public key and returns its key id. + * + * Reprint checks and canonicalizes the key, so the id returned is the one + * the client computes from its private key. + * + * @param WP_REST_Request $request The request, carrying public_key. + * @return WP_REST_Response|WP_Error The key id, or a 400, 500 or 501. + */ + public function install_public_key( WP_REST_Request $request ) { + if ( ! Utils::key_auth_required() ) { + return new WP_Error( + 'wpcom_migration_key_auth_unsupported', + __( 'This site cannot verify public keys. Rotate the export secret instead.', 'wpcom-migration' ), + array( 'status' => 501 ) + ); + } + + try { + $public_key = PublicKeyServer::assert_valid_public_key( (string) $request['public_key'] ); + } catch ( InvalidArgumentException $exception ) { + return new WP_Error( + 'wpcom_migration_invalid_public_key', + $exception->getMessage(), + array( 'status' => 400 ) + ); + } + + if ( ! Exporter::store_public_key( $public_key ) ) { + return new WP_Error( + 'wpcom_migration_public_key_not_stored', + __( 'Failed to persist the public key.', 'wpcom-migration' ), + array( 'status' => 500 ) + ); + } + + $key_id = Utils::public_key_fingerprint( $public_key ); + + Exporter::record_event( + 'public_key_installed', + array( + 'user_id' => get_current_user_id(), + 'key_id' => $key_id, + ) + ); + + return new WP_REST_Response( + array( + 'key_id' => $key_id, + 'export_url' => $this->export_url(), + ), + 200 + ); + } + /** * Opens the export window without rotating the secret, so a caller that - * already has one can reopen a window that closed. + * already has a credential can reopen a window that closed. * * @return WP_REST_Response|WP_Error The unix time the window opened at. */ public function enable_export() { $state = Exporter::get_state(); - if ( ! $state['secret_valid'] ) { + if ( ! $state['credential_valid'] ) { return new WP_Error( - 'wpcom_migration_no_secret', - __( 'Save a secret first.', 'wpcom-migration' ), + 'wpcom_migration_no_credential', + __( 'Install a public key or save a secret first.', 'wpcom-migration' ), array( 'status' => 409 ) ); } @@ -147,7 +226,7 @@ public function enable_export() { /** * An administrator, authenticated by a Jetpack user token or by core. * - * A role check, not a capability one: this hands out a secret that + * A role check, not a capability one: this installs a credential that * streams the whole database and file tree, and no capability says that. * * @return bool|WP_Error diff --git a/tests/e2e/blueprint-provisioning-key.json b/tests/e2e/blueprint-provisioning-key.json new file mode 100644 index 00000000..57c2ae50 --- /dev/null +++ b/tests/e2e/blueprint-provisioning-key.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "preferredVersions": { "php": "8.2", "wp": "latest" }, + "steps": [ + { + "step": "runPHP", + "code": " * * Scenarios: open, closed, secret-hash-deleted, enabled-hash-deleted, screen, - * provisioning, connection, menu. The secret must match the one the matching + * provisioning, provisioning-key, connection, menu. The secret must match the one the matching * blueprint stores. * * @package wpcom-migration @@ -33,14 +33,14 @@ switch ( $wpcom_migration_scenario ) { case 'open': - wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, $wpcom_migration_secret ); + wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, wpcom_migration_e2e_signed_headers( $wpcom_migration_secret ) ); break; case 'screen': // The screen blueprint ends with the window open (secret valid, // enabled); the same signed-preflight assertions prove the screen's // form handlers left the exporter in a working state. - wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, $wpcom_migration_secret ); + wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, wpcom_migration_e2e_signed_headers( $wpcom_migration_secret ) ); break; case 'menu': @@ -114,7 +114,7 @@ // nothing can ever answer. $response = wpcom_migration_e2e_request( $enable_url, $admin_headers, 'POST' ); wpcom_migration_e2e_expect_status( $response, 409 ); - wpcom_migration_e2e_expect_rest_error( $response, 'wpcom_migration_no_secret' ); + wpcom_migration_e2e_expect_rest_error( $response, 'wpcom_migration_no_credential' ); // An administrator rotates: a fresh 64-hex secret and the export URL. $response = wpcom_migration_e2e_request( $rotate_url, $admin_headers, 'POST' ); @@ -145,7 +145,7 @@ } // The secret WordPress.com received serves a real export. - wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, $rotated_secret ); + wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, wpcom_migration_e2e_signed_headers( $rotated_secret ) ); // Rotating again retires the old secret: a request signed with it is // refused, while the new one still opens the window. @@ -161,7 +161,97 @@ wpcom_migration_e2e_expect_status( $response, 403 ); wpcom_migration_e2e_expect_error_json( $response, 403 ); - wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, $new_secret ); + wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, wpcom_migration_e2e_signed_headers( $new_secret ) ); + break; + + case 'provisioning-key': + // WordPress.com provisions a site that verifies keys: it installs its + // public key, opens the window, and signs every export request with + // the private half. + $install_url = $wpcom_migration_base_url . '/wp-json/wpcom-migration/v1/reprint/install-public-key'; + $enable_url = $wpcom_migration_base_url . '/wp-json/wpcom-migration/v1/reprint/enable-export'; + + list( $private_key, $public_key ) = WordPress\Reprint\Server\PublicKeyClient::generate_keypair(); + $key_client = new WordPress\Reprint\Server\PublicKeyClient( $private_key ); + + // The route exists. + $response = wpcom_migration_e2e_request( $wpcom_migration_base_url . '/wp-json/wpcom-migration/v1', array() ); + wpcom_migration_e2e_expect_status( $response, 200 ); + $json = wpcom_migration_e2e_expect_json( $response ); + if ( ! isset( $json['routes']['/wpcom-migration/v1/reprint/install-public-key'] ) ) { + wpcom_migration_e2e_fail( 'Namespace index lacks install-public-key: ' . $response['body'] ); + } + + // Nobody: 401. An editor: 403. + $response = wpcom_migration_e2e_request( $install_url, array(), 'POST', array( 'public_key' => $public_key ) ); + wpcom_migration_e2e_expect_status( $response, 401 ); + wpcom_migration_e2e_expect_rest_error( $response, 'rest_forbidden' ); + + $response = wpcom_migration_e2e_request( $install_url, wpcom_migration_e2e_basic_auth_headers( 'e2e-editor', WPCOM_MIGRATION_E2E_EDITOR_APP_PASSWORD ), 'POST', array( 'public_key' => $public_key ) ); + wpcom_migration_e2e_expect_status( $response, 403 ); + wpcom_migration_e2e_expect_rest_error( $response, 'rest_forbidden' ); + + $admin_headers = wpcom_migration_e2e_basic_auth_headers( 'admin', WPCOM_MIGRATION_E2E_ADMIN_APP_PASSWORD ); + + // Nothing stored yet: enable refuses. + $response = wpcom_migration_e2e_request( $enable_url, $admin_headers, 'POST' ); + wpcom_migration_e2e_expect_status( $response, 409 ); + wpcom_migration_e2e_expect_rest_error( $response, 'wpcom_migration_no_credential' ); + + // Not a key, a private key, and a key under 3072 bits: each refused + // with Reprint's reason. + $weak_key = openssl_pkey_get_details( + openssl_pkey_new( + array( + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ) + ) + )['key']; + foreach ( array( 'not a key', $private_key, $weak_key ) as $bad_key ) { + $response = wpcom_migration_e2e_request( $install_url, $admin_headers, 'POST', array( 'public_key' => $bad_key ) ); + wpcom_migration_e2e_expect_status( $response, 400 ); + wpcom_migration_e2e_expect_rest_error( $response, 'wpcom_migration_invalid_public_key' ); + } + + // An administrator installs the key: the id the client computes, and + // the export URL. + $response = wpcom_migration_e2e_request( $install_url, $admin_headers, 'POST', array( 'public_key' => $public_key ) ); + wpcom_migration_e2e_expect_status( $response, 200 ); + $json = wpcom_migration_e2e_expect_json( $response ); + if ( ! isset( $json['key_id'] ) || $key_client->get_key_id() !== $json['key_id'] ) { + wpcom_migration_e2e_fail( 'Install returned the wrong key_id (want ' . $key_client->get_key_id() . '): ' . $response['body'] ); + } + if ( ! isset( $json['export_url'] ) || $wpcom_migration_base_url . '/?reprint-api-wpcom-migration' !== $json['export_url'] ) { + wpcom_migration_e2e_fail( 'Install returned an unexpected export_url: ' . $response['body'] ); + } + + // Key stored, window still closed: a key-signed request gets 409. + $response = wpcom_migration_e2e_request( $wpcom_migration_endpoint, wpcom_migration_e2e_key_signed_headers( $key_client, $wpcom_migration_endpoint ) ); + wpcom_migration_e2e_expect_status( $response, 409 ); + wpcom_migration_e2e_expect_error_json( $response, 409 ); + + // Enable, and the key serves a real export. + $response = wpcom_migration_e2e_request( $enable_url, $admin_headers, 'POST' ); + wpcom_migration_e2e_expect_status( $response, 200 ); + wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, wpcom_migration_e2e_key_signed_headers( $key_client, $wpcom_migration_endpoint ) ); + + // Installing another key, sent as PEM, retires the first. + list( $second_private_key, $second_public_key ) = WordPress\Reprint\Server\PublicKeyClient::generate_keypair(); + $second_client = new WordPress\Reprint\Server\PublicKeyClient( $second_private_key ); + + $response = wpcom_migration_e2e_request( $install_url, $admin_headers, 'POST', array( 'public_key' => WordPress\Reprint\Server\Utils::public_key_to_pem( $second_public_key ) ) ); + wpcom_migration_e2e_expect_status( $response, 200 ); + $json = wpcom_migration_e2e_expect_json( $response ); + if ( ! isset( $json['key_id'] ) || $second_client->get_key_id() !== $json['key_id'] ) { + wpcom_migration_e2e_fail( 'A PEM install returned the wrong key_id (want ' . $second_client->get_key_id() . '): ' . $response['body'] ); + } + + $response = wpcom_migration_e2e_request( $wpcom_migration_endpoint, wpcom_migration_e2e_key_signed_headers( $key_client, $wpcom_migration_endpoint ) ); + wpcom_migration_e2e_expect_status( $response, 403 ); + wpcom_migration_e2e_expect_error_json( $response, 403 ); + + wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, wpcom_migration_e2e_key_signed_headers( $second_client, $wpcom_migration_endpoint ) ); break; case 'connection': @@ -180,7 +270,7 @@ wpcom_migration_e2e_expect_rest_error( $response, 'rest_forbidden' ); // The window the REST route opened serves a real export. - wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, $wpcom_migration_secret ); + wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, wpcom_migration_e2e_signed_headers( $wpcom_migration_secret ) ); break; default: @@ -192,11 +282,11 @@ /** * Asserts a signed preflight request answers as the window being open. * - * @param string $url Preflight endpoint URL. - * @param string $secret The shared secret. + * @param string $url Preflight endpoint URL. + * @param string[] $headers Signature header lines for $url. */ -function wpcom_migration_e2e_assert_open( $url, $secret ) { - $response = wpcom_migration_e2e_request( $url, wpcom_migration_e2e_signed_headers( $secret ) ); +function wpcom_migration_e2e_assert_open( $url, array $headers ) { + $response = wpcom_migration_e2e_request( $url, $headers ); wpcom_migration_e2e_expect_status( $response, 200 ); $json = wpcom_migration_e2e_expect_json( $response ); foreach ( array( 'ok', 'protocol_version', 'php', 'wp_detect' ) as $key ) { @@ -233,6 +323,21 @@ function wpcom_migration_e2e_signed_headers( $secret ) { return $lines; } +/** + * X-Auth-* header lines for a GET signed with a private key. + * + * @param WordPress\Reprint\Server\PublicKeyClient $client The signing client. + * @param string $url The request URL; its path and query are signed. + * @return string[] + */ +function wpcom_migration_e2e_key_signed_headers( $client, $url ) { + $lines = array(); + foreach ( $client->get_auth_headers( 'GET', $url ) as $name => $value ) { + $lines[] = $name . ': ' . $value; + } + return $lines; +} + /** * A basic-auth header line for an application password, as WordPress.com * sends it. @@ -261,22 +366,25 @@ function wpcom_migration_e2e_expect_rest_error( array $response, $code ) { /** * Sends a request. * - * @param string $url Request URL. - * @param string[] $headers Header lines. - * @param string $method HTTP method. + * @param string $url Request URL. + * @param string[] $headers Header lines. + * @param string $method HTTP method. + * @param array|null $json_body Body to send as JSON, or null for none. * @return array{status: int, headers: array, body: string} */ -function wpcom_migration_e2e_request( $url, array $headers, $method = 'GET' ) { - $context = stream_context_create( - array( - 'http' => array( - 'method' => $method, - 'header' => implode( "\r\n", $headers ), - 'ignore_errors' => true, - 'timeout' => 120, - ), - ) +function wpcom_migration_e2e_request( $url, array $headers, $method = 'GET', $json_body = null ) { + $http = array( + 'method' => $method, + 'ignore_errors' => true, + 'timeout' => 120, ); + if ( null !== $json_body ) { + $headers[] = 'Content-Type: application/json'; + $http['content'] = json_encode( $json_body ); + } + $http['header'] = implode( "\r\n", $headers ); + + $context = stream_context_create( array( 'http' => $http ) ); $body = file_get_contents( $url, false, $context ); if ( false === $body ) { diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 07d43f5f..aec00528 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -2,8 +2,9 @@ # # Boots the built plugin in WordPress Playground once per credential state # and checks the export endpoint's answers, then once more to drive the -# settings screen through its form handlers, and once more to provision -# through the REST routes with application passwords. +# settings screen through its form handlers, and twice more to provision +# through the REST routes with application passwords: once with a shared +# secret and once with a public key. # # The multisite scenario runs its blueprint without a server: Playground # refuses to enable multisite on a custom port, and its steps need no HTTP. @@ -31,7 +32,7 @@ PORT="${E2E_PORT:-9400}" BASE_URL="http://127.0.0.1:$PORT" PLAYGROUND_CLI="${PLAYGROUND_CLI:-npx --yes @wp-playground/cli@3.1.54}" # shellcheck disable=SC2206 # A space-separated scenario list, split on purpose. -SCENARIOS=(${E2E_SCENARIOS:-open closed secret-hash-deleted enabled-hash-deleted screen provisioning connection menu multisite}) +SCENARIOS=(${E2E_SCENARIOS:-open closed secret-hash-deleted enabled-hash-deleted screen provisioning provisioning-key connection menu multisite}) for command_name in php npx; do if ! command -v "$command_name" >/dev/null 2>&1; then From 47c81a0f945f94463539537812321d8f3f12c4f3 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Tue, 6 Oct 2026 15:55:14 +0200 Subject: [PATCH 03/11] Count an installed public key as a Reprint credential on both screens Co-Authored-By: Claude Opus 5.5 --- plugin/reprint/class-manual-page.php | 6 ++--- plugin/reprint/class-settings-page.php | 14 ++++++------ tests/e2e/blueprint-screen.json | 24 ++++++++++++++++++++ tests/e2e/screen-steps.php | 31 +++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php index 0e2ad1d1..8df39168 100644 --- a/plugin/reprint/class-manual-page.php +++ b/plugin/reprint/class-manual-page.php @@ -211,7 +211,7 @@ public function handle_save_enabled() { $this->authorize( self::SAVE_ENABLED_ACTION ); $state = Exporter::get_state(); - if ( ! $state['secret_valid'] ) { + if ( ! $state['credential_valid'] ) { $this->redirect_with_notice( 'not_configured' ); } @@ -269,7 +269,7 @@ private function redirect_with_notice( $result ) { /** * Renders the screen: the secret form; the exporter toggle and export URL - * once the secret is valid; the blog ID when connected. + * once a credential is valid; the blog ID when connected. */ public function render_page() { if ( ! in_array( 'administrator', wp_get_current_user()->roles, true ) ) { @@ -291,7 +291,7 @@ public function render_page() { $this->render_secret_form( $state ); - if ( $state['secret_valid'] ) { + if ( $state['credential_valid'] ) { $this->render_enable_form( $state ); $this->render_api_url(); } diff --git a/plugin/reprint/class-settings-page.php b/plugin/reprint/class-settings-page.php index 67b500e9..dec1d6b9 100644 --- a/plugin/reprint/class-settings-page.php +++ b/plugin/reprint/class-settings-page.php @@ -41,14 +41,14 @@ class Settings_Page { const MODE_BLOCKED = 'blocked'; /** - * A secret is stored but no longer matches the site's salts. + * A credential is stored but none matches the site's salts. * * @var string */ const MODE_BROKEN = 'broken'; /** - * The secret is valid and the exporter is on. + * A credential is valid and the exporter is on. * * @var string */ @@ -62,7 +62,7 @@ class Settings_Page { const MODE_CONNECTED_WAITING = 'connected_waiting'; /** - * WordPress.com installed a secret; the exporter is off; no user connection. + * WordPress.com installed a credential; the exporter is off; no user connection. * * @var string */ @@ -267,11 +267,11 @@ public static function mode( array $state, $user_connected ) { return self::MODE_BLOCKED; } - if ( $state['has_secret'] && ! $state['secret_valid'] ) { + if ( $state['has_credential'] && ! $state['credential_valid'] ) { return self::MODE_BROKEN; } - if ( $state['secret_valid'] && $state['window_open'] ) { + if ( $state['credential_valid'] && $state['window_open'] ) { return self::MODE_READY; } @@ -279,7 +279,7 @@ public static function mode( array $state, $user_connected ) { return self::MODE_CONNECTED_WAITING; } - if ( $state['secret_valid'] ) { + if ( $state['credential_valid'] ) { return self::MODE_PROVISIONED_WAITING; } @@ -304,7 +304,7 @@ private function render_mode( $mode, array $state, $user_connected ) { return; case self::MODE_BROKEN: - $this->render_sentence( __( 'The export secret no longer matches this site — its security salts changed. Start the migration again on WordPress.com.', 'wpcom-migration' ) ); + $this->render_sentence( __( 'The export credential no longer matches this site — its security salts changed. Start the migration again on WordPress.com.', 'wpcom-migration' ) ); if ( null !== $section && $user_connected ) { $section->render_continue_button( true ); $section->render_disconnect_link(); diff --git a/tests/e2e/blueprint-screen.json b/tests/e2e/blueprint-screen.json index 9df93e91..cce1727b 100644 --- a/tests/e2e/blueprint-screen.json +++ b/tests/e2e/blueprint-screen.json @@ -74,6 +74,30 @@ "step": "runPHP", "code": " Date: Tue, 6 Oct 2026 15:57:20 +0200 Subject: [PATCH 04/11] Refuse to rotate the Reprint secret where public keys are verified Co-Authored-By: Claude Opus 5.5 --- plugin/reprint/class-rest-controller.php | 13 ++- tests/e2e/blueprint-connection.json | 10 +-- ....json => blueprint-provisioning-hmac.json} | 4 + tests/e2e/connection-steps.php | 85 ++++++++++++------- tests/e2e/hmac-only-mu-plugin.php | 11 +++ tests/e2e/request.php | 25 ++++-- tests/e2e/run.sh | 2 +- 7 files changed, 104 insertions(+), 46 deletions(-) rename tests/e2e/{blueprint-provisioning.json => blueprint-provisioning-hmac.json} (74%) create mode 100644 tests/e2e/hmac-only-mu-plugin.php diff --git a/plugin/reprint/class-rest-controller.php b/plugin/reprint/class-rest-controller.php index c085524c..6dd0921c 100644 --- a/plugin/reprint/class-rest-controller.php +++ b/plugin/reprint/class-rest-controller.php @@ -107,9 +107,20 @@ public function export_url() { * for passwords a person types, and sites can filter it through * `random_password`, an extension point a credential should not have. * - * @return WP_REST_Response|WP_Error The new secret, or a 500. + * @return WP_REST_Response|WP_Error The new secret, or a 500 or 501. */ public function rotate_secret() { + // Where OpenSSL can verify keys, Reprint signs with them and its + // servers stop accepting secrets, so a secret issued here would go + // unused. + if ( Utils::key_auth_required() ) { + return new WP_Error( + 'wpcom_migration_secret_auth_unsupported', + __( 'This site verifies exports with public keys. Install a public key instead.', 'wpcom-migration' ), + array( 'status' => 501 ) + ); + } + $secret = bin2hex( random_bytes( 32 ) ); if ( ! Exporter::store_secret( $secret ) ) { diff --git a/tests/e2e/blueprint-connection.json b/tests/e2e/blueprint-connection.json index cbcdba65..4cdcfc2d 100644 --- a/tests/e2e/blueprint-connection.json +++ b/tests/e2e/blueprint-connection.json @@ -80,19 +80,19 @@ }, { "step": "runPHP", - "code": "set_param( 'public_key', wpcom_migration_e2e_public_key() ); + wpcom_migration_e2e_expect_rest_status( rest_do_request( $request ), 403, $step ); + if ( get_option( Exporter::PUBLIC_KEY_OPTION ) ) { + throw new RuntimeException( "Step '$step': a refused request must not store a key." ); } break; - case 'rest-rotate-secret-cookie-nonce': + case 'rest-install-public-key-cookie-nonce': // A logged-in administrator with a valid REST nonce: core's // cookie lane. wp_set_current_user( 1 ); $_REQUEST['_wpnonce'] = wp_create_nonce( 'wp_rest' ); - $response = rest_do_request( new WP_REST_Request( 'POST', '/wpcom-migration/v1/reprint/rotate-export-secret' ) ); + $request = new WP_REST_Request( 'POST', '/wpcom-migration/v1/reprint/install-public-key' ); + $request->set_param( 'public_key', wpcom_migration_e2e_public_key() ); + $response = rest_do_request( $request ); unset( $_REQUEST['_wpnonce'] ); wpcom_migration_e2e_expect_rest_status( $response, 200, $step ); - if ( ! get_option( Exporter::SECRET_OPTION ) ) { - throw new RuntimeException( "Step '$step': a cookie-and-nonce rotation should store a secret." ); + if ( ! get_option( Exporter::PUBLIC_KEY_OPTION ) ) { + throw new RuntimeException( "Step '$step': a cookie-and-nonce install should store a key." ); } break; - case 'rest-rotate-secret-cookie-bad-nonce': + case 'rest-install-public-key-cookie-bad-nonce': wp_set_current_user( 1 ); $_REQUEST['_wpnonce'] = 'not-a-nonce'; - $response = rest_do_request( new WP_REST_Request( 'POST', '/wpcom-migration/v1/reprint/rotate-export-secret' ) ); + $request = new WP_REST_Request( 'POST', '/wpcom-migration/v1/reprint/install-public-key' ); + $request->set_param( 'public_key', wpcom_migration_e2e_public_key() ); + $response = rest_do_request( $request ); unset( $_REQUEST['_wpnonce'] ); wpcom_migration_e2e_expect_rest_status( $response, 403, $step ); break; - case 'rest-rotate-secret-user-token': - $response = wpcom_migration_e2e_signed_rest_request( '/wpcom-migration/v1/reprint/rotate-export-secret', WPCOM_MIGRATION_E2E_USER_TOKEN, 1 ); + case 'rest-install-public-key-user-token': + $public_key = wpcom_migration_e2e_public_key(); + $response = wpcom_migration_e2e_signed_rest_request( '/wpcom-migration/v1/reprint/install-public-key', WPCOM_MIGRATION_E2E_USER_TOKEN, 1, array( 'public_key' => $public_key ) ); wpcom_migration_e2e_expect_rest_status( $response, 200, $step ); $data = $response->get_data(); - if ( ! isset( $data['secret'] ) || ! preg_match( '/^[0-9a-f]{64}$/', $data['secret'] ) ) { - throw new RuntimeException( "Step '$step': expected a 64-hex secret, got: " . wp_json_encode( $data ) ); + if ( ! isset( $data['key_id'] ) || \WordPress\Reprint\Server\Utils::public_key_fingerprint( $public_key ) !== $data['key_id'] ) { + throw new RuntimeException( "Step '$step': unexpected key_id: " . wp_json_encode( $data ) ); } if ( ! isset( $data['export_url'] ) || home_url( '/?' . Exporter::QUERY_VAR ) !== $data['export_url'] ) { throw new RuntimeException( "Step '$step': unexpected export_url: " . wp_json_encode( $data ) ); } $state = Exporter::get_state(); - if ( ! $state['secret_valid'] || $state['window_open'] ) { - throw new RuntimeException( "Step '$step': rotating should store a valid secret and leave the window closed: " . wp_json_encode( $state ) ); + if ( ! $state['public_key_valid'] || $state['window_open'] ) { + throw new RuntimeException( "Step '$step': installing should store a valid key and leave the window closed: " . wp_json_encode( $state ) ); } break; @@ -250,10 +256,10 @@ function ( $entry ) { break; case 'render-broken-connected': - // Break the secret, look, then mend it so the later steps still - // find a valid one. - $secret = get_option( Exporter::SECRET_OPTION ); - delete_option( Exporter::SECRET_HASH_OPTION ); + // Break the key, look, then mend it so the later steps still find + // a valid one. + $public_key = get_option( Exporter::PUBLIC_KEY_OPTION ); + delete_option( Exporter::PUBLIC_KEY_HASH_OPTION ); wpcom_migration_e2e_expect_mode( Settings_Page::MODE_BROKEN, $step ); $html = wpcom_migration_e2e_render_connect_page(); wpcom_migration_e2e_expect_contains( $html, 'no longer matches this site', $step ); @@ -261,22 +267,22 @@ function ( $entry ) { wpcom_migration_e2e_expect_contains( $html, 'Continue on WordPress.com', $step ); wpcom_migration_e2e_expect_primary_count( $html, 1, $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Log in with WordPress.com', $step ); - if ( ! Exporter::store_secret( $secret ) ) { - throw new RuntimeException( "Step '$step': could not restore the secret." ); + if ( ! Exporter::store_public_key( $public_key ) ) { + throw new RuntimeException( "Step '$step': could not restore the key." ); } wpcom_migration_e2e_expect_mode( Settings_Page::MODE_READY, $step ); break; - case 'rest-rotate-secret-blog-token': + case 'rest-install-public-key-blog-token': // A blog token sets no user, so WordPress answers 401, not 403. - $secret_before = get_option( Exporter::SECRET_OPTION ); - if ( ! $secret_before ) { - throw new RuntimeException( "Step '$step': the earlier user-token rotation should have left a secret." ); + $key_before = get_option( Exporter::PUBLIC_KEY_OPTION ); + if ( ! $key_before ) { + throw new RuntimeException( "Step '$step': the earlier user-token install should have left a key." ); } - $response = wpcom_migration_e2e_signed_rest_request( '/wpcom-migration/v1/reprint/rotate-export-secret', WPCOM_MIGRATION_E2E_BLOG_TOKEN, 0 ); + $response = wpcom_migration_e2e_signed_rest_request( '/wpcom-migration/v1/reprint/install-public-key', WPCOM_MIGRATION_E2E_BLOG_TOKEN, 0, array( 'public_key' => wpcom_migration_e2e_public_key() ) ); wpcom_migration_e2e_expect_rest_status( $response, 401, $step ); - if ( get_option( Exporter::SECRET_OPTION ) !== $secret_before ) { - throw new RuntimeException( "Step '$step': a refused request must not rotate the secret." ); + if ( get_option( Exporter::PUBLIC_KEY_OPTION ) !== $key_before ) { + throw new RuntimeException( "Step '$step': a refused request must not replace the key." ); } break; @@ -426,10 +432,11 @@ function ( $location ) { * @param string $route REST route, e.g. /wpcom-migration/v1/reprint/enable-export. * @param string $access_token "key.secret" for a blog token, "key.secret.user_id" for a user token. * @param int $user_id 0 for a blog token. + * @param array $params REST parameters. * @return WP_REST_Response * @throws RuntimeException When signing fails. */ -function wpcom_migration_e2e_signed_rest_request( $route, $access_token, $user_id ) { +function wpcom_migration_e2e_signed_rest_request( $route, $access_token, $user_id, array $params = array() ) { $token_parts = explode( '.', $access_token ); $token_key = $token_parts[0]; @@ -458,7 +465,21 @@ function wpcom_migration_e2e_signed_rest_request( $route, $access_token, $user_i Rest_Authentication::init()->reset_saved_auth_state(); wp_get_current_user(); - return rest_do_request( new WP_REST_Request( 'POST', $route ) ); + $request = new WP_REST_Request( 'POST', $route ); + foreach ( $params as $name => $value ) { + $request->set_param( $name, $value ); + } + return rest_do_request( $request ); +} + +/** + * A fresh one-line public key, as WordPress.com would send. + * + * @return string + */ +function wpcom_migration_e2e_public_key() { + list( , $public_key ) = \WordPress\Reprint\Server\PublicKeyClient::generate_keypair(); + return $public_key; } /** diff --git a/tests/e2e/hmac-only-mu-plugin.php b/tests/e2e/hmac-only-mu-plugin.php new file mode 100644 index 00000000..01a2502f --- /dev/null +++ b/tests/e2e/hmac-only-mu-plugin.php @@ -0,0 +1,11 @@ + * * Scenarios: open, closed, secret-hash-deleted, enabled-hash-deleted, screen, - * provisioning, provisioning-key, connection, menu. The secret must match the one the matching + * provisioning-hmac, provisioning-key, connection, menu. The secret must match the one the matching * blueprint stores. * * @package wpcom-migration @@ -82,10 +82,11 @@ wpcom_migration_e2e_expect_error_json( $response, 409 ); break; - case 'provisioning': - // WordPress.com installs the plugin through core, then provisions the - // exporter with an application password. All over HTTP with basic - // auth, as WordPress.com sends it. + case 'provisioning-hmac': + // On a host without OpenSSL, WordPress.com installs the plugin through + // core, then provisions the exporter with a shared secret and an + // application password. All over HTTP with basic auth, as + // WordPress.com sends it. $rotate_url = $wpcom_migration_base_url . '/wp-json/wpcom-migration/v1/reprint/rotate-export-secret'; $enable_url = $wpcom_migration_base_url . '/wp-json/wpcom-migration/v1/reprint/enable-export'; @@ -93,7 +94,7 @@ $response = wpcom_migration_e2e_request( $wpcom_migration_base_url . '/wp-json/wpcom-migration/v1', array() ); wpcom_migration_e2e_expect_status( $response, 200 ); $json = wpcom_migration_e2e_expect_json( $response ); - foreach ( array( '/wpcom-migration/v1/reprint/rotate-export-secret', '/wpcom-migration/v1/reprint/enable-export' ) as $route ) { + foreach ( array( '/wpcom-migration/v1/reprint/rotate-export-secret', '/wpcom-migration/v1/reprint/install-public-key', '/wpcom-migration/v1/reprint/enable-export' ) as $route ) { if ( ! isset( $json['routes'][ $route ] ) ) { wpcom_migration_e2e_fail( "Namespace index lacks $route: " . $response['body'] ); } @@ -110,6 +111,11 @@ $admin_headers = wpcom_migration_e2e_basic_auth_headers( 'admin', WPCOM_MIGRATION_E2E_ADMIN_APP_PASSWORD ); + // No OpenSSL, so no key could ever be verified. + $response = wpcom_migration_e2e_request( $wpcom_migration_base_url . '/wp-json/wpcom-migration/v1/reprint/install-public-key', $admin_headers, 'POST', array( 'public_key' => 'irrelevant' ) ); + wpcom_migration_e2e_expect_status( $response, 501 ); + wpcom_migration_e2e_expect_rest_error( $response, 'wpcom_migration_key_auth_unsupported' ); + // No secret stored yet: enable refuses rather than open a window // nothing can ever answer. $response = wpcom_migration_e2e_request( $enable_url, $admin_headers, 'POST' ); @@ -193,6 +199,11 @@ $admin_headers = wpcom_migration_e2e_basic_auth_headers( 'admin', WPCOM_MIGRATION_E2E_ADMIN_APP_PASSWORD ); + // This host verifies keys, so a secret would never be accepted. + $response = wpcom_migration_e2e_request( $wpcom_migration_base_url . '/wp-json/wpcom-migration/v1/reprint/rotate-export-secret', $admin_headers, 'POST' ); + wpcom_migration_e2e_expect_status( $response, 501 ); + wpcom_migration_e2e_expect_rest_error( $response, 'wpcom_migration_secret_auth_unsupported' ); + // Nothing stored yet: enable refuses. $response = wpcom_migration_e2e_request( $enable_url, $admin_headers, 'POST' ); wpcom_migration_e2e_expect_status( $response, 409 ); @@ -259,7 +270,7 @@ $response = wpcom_migration_e2e_request( $wpcom_migration_base_url . '/wp-json/wpcom-migration/v1', array() ); wpcom_migration_e2e_expect_status( $response, 200 ); $json = wpcom_migration_e2e_expect_json( $response ); - foreach ( array( '/wpcom-migration/v1/reprint/rotate-export-secret', '/wpcom-migration/v1/reprint/enable-export' ) as $route ) { + foreach ( array( '/wpcom-migration/v1/reprint/rotate-export-secret', '/wpcom-migration/v1/reprint/install-public-key', '/wpcom-migration/v1/reprint/enable-export' ) as $route ) { if ( ! isset( $json['routes'][ $route ] ) ) { wpcom_migration_e2e_fail( "Namespace index lacks $route: " . $response['body'] ); } diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index aec00528..a40c00f3 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -32,7 +32,7 @@ PORT="${E2E_PORT:-9400}" BASE_URL="http://127.0.0.1:$PORT" PLAYGROUND_CLI="${PLAYGROUND_CLI:-npx --yes @wp-playground/cli@3.1.54}" # shellcheck disable=SC2206 # A space-separated scenario list, split on purpose. -SCENARIOS=(${E2E_SCENARIOS:-open closed secret-hash-deleted enabled-hash-deleted screen provisioning provisioning-key connection menu multisite}) +SCENARIOS=(${E2E_SCENARIOS:-open closed secret-hash-deleted enabled-hash-deleted screen provisioning-hmac provisioning-key connection menu multisite}) for command_name in php npx; do if ! command -v "$command_name" >/dev/null 2>&1; then From 0366e4003560b4e8fbe8828e839e87803bb2a0c0 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Tue, 6 Oct 2026 16:01:05 +0200 Subject: [PATCH 05/11] Give the HMAC-only test mu-plugin a recognisable file comment Co-Authored-By: Claude Opus 5.5 --- tests/e2e/hmac-only-mu-plugin.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/hmac-only-mu-plugin.php b/tests/e2e/hmac-only-mu-plugin.php index 01a2502f..5c11d451 100644 --- a/tests/e2e/hmac-only-mu-plugin.php +++ b/tests/e2e/hmac-only-mu-plugin.php @@ -6,6 +6,8 @@ * @package wpcom-migration */ +use WordPress\Reprint\Server\Utils; + require_once WP_PLUGIN_DIR . '/wpcom-migration/vendor/wp-php-toolkit/reprint-server/src/class-utils.php'; -\WordPress\Reprint\Server\Utils::override_key_auth_required_for_tests( false ); +Utils::override_key_auth_required_for_tests( false ); From e63b9c6b3da1557ff9bb724c13ae26e61781c760 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Tue, 6 Oct 2026 16:01:23 +0200 Subject: [PATCH 06/11] Document public-key provisioning for Reprint Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9a1be8f8..33aa45cc 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ Source for the [Migrate to WordPress.com](https://wordpress.org/plugins/wpcom-mi ## Layout - `plugin/` — plugin source. The BlogVault tree plus `reprint/`, the Reprint export glue. -- `plugin/reprint/` — `Exporter` (credentials, write veto, `?reprint-api-wpcom-migration`), `Settings_Page` (the Reprint migration screen), `Manual_Page` (the by-hand screen), `REST_Controller` (provisioning routes), `bootstrap.php`. +- `plugin/reprint/` — `Exporter` (the shared secret and public key, write veto, `?reprint-api-wpcom-migration`), `Settings_Page` (the Reprint migration screen), `Manual_Page` (the by-hand screen), `REST_Controller` (provisioning routes), `bootstrap.php`. - `plugin/connection/` — `Connection` (package setup, connect, disconnect), `Connect_Page` (the connection section of the screen), `bootstrap.php`. - `bin/build.sh` — writes `build/wpcom-migration/` and `build/wpcom-migration.zip`. - `bin/check-autoload-manifest.php` — asserts which classes the ZIP publishes through the Jetpack autoloader. -- `tests/e2e/` — Playground blueprints and request scripts: one scenario per credential state, one that drives the Reprint migration screen, one that provisions through the REST routes, one that drives the WordPress.com connection, one that checks the admin menu, and one that checks the menu on a network. +- `tests/e2e/` — Playground blueprints and request scripts: one scenario per credential state, one that drives the Reprint migration screen, two that provision through the REST routes (with a public key, and with a shared secret on a host made to look like it lacks OpenSSL), one that drives the WordPress.com connection, one that checks the admin menu, and one that checks the menu on a network. ## Build @@ -31,7 +31,7 @@ To activate a source checkout directly (without a build), run `composer install | Mode | When | Shows | |---|---|---| | The exporter runs on single sites only | Multisite | Nothing | -| The export secret no longer matches this site | The site's salts changed | *Log in with WordPress.com*, or *Continue on WordPress.com* when connected | +| The export credential no longer matches this site | The site's salts changed | *Log in with WordPress.com*, or *Continue on WordPress.com* when connected | | The exporter is on until *time* | A migration is running | *Continue on WordPress.com* and *Disconnect*, when connected | | Connected as *email* | Logged in; the migration has not started | *Continue on WordPress.com*, a *Disconnect* link | | This site is set up and ready | Provisioned with an application password | A *Log in with WordPress.com* link | @@ -47,11 +47,13 @@ On a network the old screen keeps its row in the network admin, and the *Setting ## Setting up Reprint manually -`wp-admin/admin.php?page=wpcom-migration-manual` (`administrator` role, single-site only). Nothing links to it; support gives out the URL. It holds the export secret form, a *Remove secret* button (which also turns the exporter off), the exporter toggle, the export URL and the WordPress.com blog ID. Deactivating the plugin disconnects and discards the secret and the exporter state. +`wp-admin/admin.php?page=wpcom-migration-manual` (`administrator` role, single-site only). Nothing links to it; support gives out the URL. It holds the export secret form, a *Remove secret* button (which also removes an installed public key and turns the exporter off), the exporter toggle, the export URL and the WordPress.com blog ID. Deactivating the plugin disconnects and discards the secret and the exporter state. ## How WordPress.com provisions the exporter -Two lanes end at the same two routes, `POST /wp-json/wpcom-migration/v1/reprint/rotate-export-secret` → `{ "secret", "export_url" }` and `POST …/enable-export` → `{ "enabled_at", "export_url" }`. Both require an administrator, authenticated either by core (an application password) or by a Jetpack user token; `enable-export` answers 409 until a valid secret is stored, and both answer 501 on a network. Reprint transfers over https only. +Two lanes end at the same three routes: `POST /wp-json/wpcom-migration/v1/reprint/install-public-key {"public_key"}` → `{ "key_id", "export_url" }`, `POST …/rotate-export-secret` → `{ "secret", "export_url" }` and `POST …/enable-export` → `{ "enabled_at", "export_url" }`. All require an administrator, authenticated either by core (an application password) or by a Jetpack user token, and all answer 501 on a network. Reprint transfers over https only. + +Which credential WordPress.com sets up depends on the host. Where `openssl_verify()` exists, Reprint signs with keys: WordPress.com installs its public key (PEM or one line of base64, RSA, 3072 bits or more; anything else is a 400 `wpcom_migration_invalid_public_key`), and `rotate-export-secret` answers 501 `wpcom_migration_secret_auth_unsupported`. Elsewhere it rotates the secret, and `install-public-key` answers 501 `wpcom_migration_key_auth_unsupported`. The site holds one key at a time; installing another replaces it. `enable-export` answers 409 `wpcom_migration_no_credential` until either credential is stored and valid. Export requests are verified by Reprint's `RequestAuthenticator`, which accepts whichever of the two the host supports. **Application password** — where `GET /wp-json/` lists `authentication.application-passwords` (core: `is_ssl()` true and no plugin has switched them off). WordPress.com sends the administrator once to `wp-admin/authorize-application.php?app_name=Migrate+to+WordPress.com&app_id=&success_url=…&reject_url=…`; core redirects to `success_url` with `site_url`, `user_login` and `password`. WordPress.com then checks the password with `GET /wp-json/wp/v2/users/me` (a 401 here means the server strips the `Authorization` header — Apache CGI without the rewrite rule WordPress 5.6 added; saving Permalinks regenerates it), installs and activates the plugin (`POST /wp-json/wp/v2/plugins {"slug":"wpcom-migration","status":"active"}`; an older copy is replaced by `PUT …/plugins/wpcom-migration/wpcom_migration {"status":"inactive"}`, `DELETE`, then the `POST`, since core has no update route), calls the two routes, exports, and revokes the password (`GET …/users/me/application-passwords/introspect`, then `DELETE …/application-passwords/`). No login on the site. From 76aead7e500c01ad3995c2ca2ff8bfff5b436086 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Tue, 6 Oct 2026 16:10:00 +0200 Subject: [PATCH 07/11] Offer to remove an installed public key on the manual screen Co-Authored-By: Claude Opus 5.5 --- plugin/reprint/class-manual-page.php | 15 +++++++++------ tests/e2e/screen-steps.php | 2 ++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php index 8df39168..f60faafb 100644 --- a/plugin/reprint/class-manual-page.php +++ b/plugin/reprint/class-manual-page.php @@ -343,21 +343,24 @@ class="button wpcom-migration-reprint-toggle-secret"

render_discard_form(); + if ( $state['has_credential'] ) { + $this->render_discard_form( $state ); } } /** - * Renders the form that removes the secret. + * Renders the form that removes the stored credentials, labelled for the + * secret when one is stored and for the public key otherwise. + * + * @param array $state Exporter::get_state(). */ - private function render_discard_form() { + private function render_discard_form( array $state ) { ?>
- -

+ +

Date: Wed, 7 Oct 2026 15:21:59 +0200 Subject: [PATCH 08/11] Manage the Reprint public key on the manual screen The screen gets a public key textarea and a table with the enrolled key's id and a Remove button, modelled on the Reprint Server plugin. Remove secret now removes only the secret, so each credential is managed on its own. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- plugin/reprint/class-exporter.php | 41 +++++- plugin/reprint/class-manual-page.php | 196 +++++++++++++++++++++++---- tests/e2e/blueprint-screen.json | 44 +++++- tests/e2e/screen-steps.php | 75 ++++++++-- 5 files changed, 315 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 33aa45cc..8dfd95f0 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ On a network the old screen keeps its row in the network admin, and the *Setting ## Setting up Reprint manually -`wp-admin/admin.php?page=wpcom-migration-manual` (`administrator` role, single-site only). Nothing links to it; support gives out the URL. It holds the export secret form, a *Remove secret* button (which also removes an installed public key and turns the exporter off), the exporter toggle, the export URL and the WordPress.com blog ID. Deactivating the plugin disconnects and discards the secret and the exporter state. +`wp-admin/admin.php?page=wpcom-migration-manual` (`administrator` role, single-site only). Nothing links to it; support gives out the URL. It holds the export secret form and a *Remove secret* button; on a host with `openssl_verify()`, a public key textarea (*Enroll key*, PEM or one line, replacing any enrolled key) and a table with the enrolled key's id and a *Remove* button; the exporter toggle, the export URL and the WordPress.com blog ID. Removing either credential turns the exporter off and leaves the other in place. Deactivating the plugin disconnects and discards the secret and the exporter state. ## How WordPress.com provisions the exporter diff --git a/plugin/reprint/class-exporter.php b/plugin/reprint/class-exporter.php index c3cd03a3..e6095f6a 100644 --- a/plugin/reprint/class-exporter.php +++ b/plugin/reprint/class-exporter.php @@ -205,8 +205,9 @@ public static function record_event( $event, array $context = array() ) { * secret, a credential hash or the signature. * * @param string $event One of export_served, export_refused, - * secret_rotated, public_key_installed, - * window_opened, window_closed, + * secret_rotated, secret_removed, + * public_key_installed, public_key_removed, + * window_opened, window_closed, * credentials_discarded, credential_hash_mismatch. * @param array $context Details of the event. */ @@ -217,8 +218,7 @@ public static function record_event( $event, array $context = array() ) { * Discards any stored export credentials. * * Runs on plugin activation and deactivation, clearing whatever was - * written while protect_options() was not in place; on disconnect; and - * from the by-hand screen's Remove secret button. + * written while protect_options() was not in place; and on disconnect. */ public static function discard_credentials() { $had_any = false; @@ -234,6 +234,39 @@ public static function discard_credentials() { } } + /** + * Discards the stored secret and turns the exporter off, leaving any + * public key in place. + */ + public static function discard_secret() { + self::discard_credential( self::SECRET_OPTION, self::SECRET_HASH_OPTION, 'secret_removed' ); + } + + /** + * Discards the stored public key and turns the exporter off, leaving any + * secret in place. + */ + public static function discard_public_key() { + self::discard_credential( self::PUBLIC_KEY_OPTION, self::PUBLIC_KEY_HASH_OPTION, 'public_key_removed' ); + } + + /** + * Deletes one credential and its hash, and closes the export window. + * + * @param string $option The credential's option. + * @param string $hash_option The option holding its hash. + * @param string $event Event to report when anything was deleted. + */ + private static function discard_credential( $option, $hash_option, $event ) { + $had_any = delete_option( $option ); + $had_any = delete_option( $hash_option ) || $had_any; + self::close_export_window(); + + if ( $had_any ) { + self::record_event( $event, array( 'user_id' => get_current_user_id() ) ); + } + } + /** * Stores a shared secret together with its salt-keyed hash. * diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php index f60faafb..11d46d1c 100644 --- a/plugin/reprint/class-manual-page.php +++ b/plugin/reprint/class-manual-page.php @@ -1,6 +1,7 @@ authorize( self::DISCARD_SECRET_ACTION ); - Exporter::discard_credentials(); + Exporter::discard_secret(); $this->redirect_with_notice( 'discarded' ); } + /** + * Enrolls the pasted public key, replacing any other, and redirects back + * with a result notice. + */ + public function handle_save_public_key() { + $this->authorize( self::SAVE_PUBLIC_KEY_ACTION ); + + if ( ! Utils::key_auth_required() ) { + $this->redirect_with_notice( 'key_unsupported' ); + } + + // PublicKeyServer::assert_valid_public_key() is the real check; the + // sanitizer only cleans the pasted text. + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked in authorize(). + $pasted_key = sanitize_textarea_field( wp_unslash( $_POST[ self::PUBLIC_KEY_FIELD ] ?? '' ) ); + + try { + $public_key = PublicKeyServer::assert_valid_public_key( $pasted_key ); + } catch ( InvalidArgumentException $exception ) { + $this->redirect_with_notice( 'key_invalid' ); + } + + $state = Exporter::get_state(); + if ( $state['public_key_valid'] && get_option( Exporter::PUBLIC_KEY_OPTION ) === $public_key ) { + $this->redirect_with_notice( 'key_unchanged' ); + } + + if ( ! Exporter::store_public_key( $public_key ) ) { + $this->redirect_with_notice( 'key_storage_failure' ); + } + + Exporter::record_event( + 'public_key_installed', + array( + 'user_id' => get_current_user_id(), + 'key_id' => Utils::public_key_fingerprint( $public_key ), + ) + ); + $this->redirect_with_notice( 'key_saved' ); + } + + /** + * Removes the public key, turns the exporter off, and redirects back with + * a result notice. + */ + public function handle_remove_public_key() { + $this->authorize( self::REMOVE_PUBLIC_KEY_ACTION ); + + Exporter::discard_public_key(); + $this->redirect_with_notice( 'key_removed' ); + } + /** * Stops a form post that is not a nonce-carrying administrator request on * a single site. @@ -260,7 +341,10 @@ private function authorize( $action ) { * Redirects back to the screen with a result code and exits. * * @param string $result One of saved, unchanged, enabled, disabled, - * discarded, not_configured, storage_failure. + * discarded, not_configured, storage_failure, + * key_saved, key_unchanged, key_removed, + * key_invalid, key_unsupported, + * key_storage_failure. */ private function redirect_with_notice( $result ) { wp_safe_redirect( add_query_arg( self::NOTICE_QUERY_ARG, $result, self::page_url() ) ); @@ -268,8 +352,9 @@ private function redirect_with_notice( $result ) { } /** - * Renders the screen: the secret form; the exporter toggle and export URL - * once a credential is valid; the blog ID when connected. + * Renders the screen: the secret form; the public key form and table; the + * exporter toggle and export URL once a credential is valid; the blog ID + * when connected. */ public function render_page() { if ( ! in_array( 'administrator', wp_get_current_user()->roles, true ) ) { @@ -290,6 +375,7 @@ public function render_page() { echo '

' . esc_html__( 'Support may ask you to set this up by hand.', 'wpcom-migration' ) . '

'; $this->render_secret_form( $state ); + $this->render_public_key_section( $state ); if ( $state['credential_valid'] ) { $this->render_enable_form( $state ); @@ -343,26 +429,84 @@ class="button wpcom-migration-reprint-toggle-secret"

render_discard_form( $state ); + if ( $state['has_secret'] ) { + $this->render_discard_form(); } } /** - * Renders the form that removes the stored credentials, labelled for the - * secret when one is stored and for the public key otherwise. - * - * @param array $state Exporter::get_state(). + * Renders the form that removes the secret. */ - private function render_discard_form( array $state ) { + private function render_discard_form() { ?>
- -

+ +

+
+ +
+

+ ' . esc_html__( 'Public keys need OpenSSL, which this host does not have.', 'wpcom-migration' ) . '

'; + return; + } + ?> +
+ + +

+
+ +

+

+

+ + + + + + + + + + + + + +
+
+ + + +
+
+

+ /> -

+

array( 'success', __( 'Secret saved.', 'wpcom-migration' ) ), - 'unchanged' => array( 'success', __( 'The secret was already up to date.', 'wpcom-migration' ) ), - 'enabled' => array( 'success', __( 'Exporter on for the next hour.', 'wpcom-migration' ) ), - 'disabled' => array( 'success', __( 'Exporter off.', 'wpcom-migration' ) ), - 'discarded' => array( 'success', __( 'Secret removed. The exporter is off.', 'wpcom-migration' ) ), - 'not_configured' => array( 'error', __( 'Enter an export secret first.', 'wpcom-migration' ) ), - 'storage_failure' => array( 'error', __( 'The secret could not be saved.', 'wpcom-migration' ) ), + 'saved' => array( 'success', __( 'Secret saved.', 'wpcom-migration' ) ), + 'unchanged' => array( 'success', __( 'The secret was already up to date.', 'wpcom-migration' ) ), + 'enabled' => array( 'success', __( 'Exporter on for the next hour.', 'wpcom-migration' ) ), + 'disabled' => array( 'success', __( 'Exporter off.', 'wpcom-migration' ) ), + 'discarded' => array( 'success', __( 'Secret removed. The exporter is off.', 'wpcom-migration' ) ), + 'not_configured' => array( 'error', __( 'Enter an export secret first.', 'wpcom-migration' ) ), + 'storage_failure' => array( 'error', __( 'The secret could not be saved.', 'wpcom-migration' ) ), + 'key_saved' => array( 'success', __( 'Public key saved.', 'wpcom-migration' ) ), + 'key_unchanged' => array( 'success', __( 'That public key was already enrolled.', 'wpcom-migration' ) ), + 'key_removed' => array( 'success', __( 'Public key removed. The exporter is off.', 'wpcom-migration' ) ), + 'key_invalid' => array( 'error', __( 'That is not a usable public key. Paste the RSA public key, 3072 bits or more, printed by "reprint keygen".', 'wpcom-migration' ) ), + 'key_unsupported' => array( 'error', __( 'Public keys need OpenSSL, which this host does not have.', 'wpcom-migration' ) ), + 'key_storage_failure' => array( 'error', __( 'The public key could not be saved.', 'wpcom-migration' ) ), ); if ( ! isset( $notices[ $result ] ) ) { diff --git a/tests/e2e/blueprint-screen.json b/tests/e2e/blueprint-screen.json index cce1727b..66c1177b 100644 --- a/tests/e2e/blueprint-screen.json +++ b/tests/e2e/blueprint-screen.json @@ -76,7 +76,23 @@ }, { "step": "runPHP", - "code": " 'not a key' ) ); + $manual->handle_save_public_key(); + break; + + case 'assert-no-public-key': $state = Exporter::get_state(); - if ( $state['has_secret'] || ! $state['public_key_valid'] || ! $state['credential_valid'] || $state['window_open'] ) { - throw new RuntimeException( "Step '$step': expected a valid key, no secret, window closed: " . wp_json_encode( $state ) ); + if ( $state['has_public_key'] ) { + throw new RuntimeException( "Step '$step': an invalid key must not be stored: " . wp_json_encode( $state ) ); + } + break; + + case 'enroll-public-key': + // A PEM block, as an administrator would paste it; the one-line + // form is kept to compare against what gets stored. + list( , $public_key ) = \WordPress\Reprint\Server\PublicKeyClient::generate_keypair(); + update_option( 'wpcom_migration_e2e_public_key', $public_key ); + wpcom_migration_e2e_post( Manual_Page::SAVE_PUBLIC_KEY_ACTION, array( Manual_Page::PUBLIC_KEY_FIELD => \WordPress\Reprint\Server\Utils::public_key_to_pem( $public_key ) ) ); + $manual->handle_save_public_key(); + break; + + case 'assert-public-key-enrolled': + $public_key = get_option( 'wpcom_migration_e2e_public_key' ); + $state = Exporter::get_state(); + if ( get_option( Exporter::PUBLIC_KEY_OPTION ) !== $public_key || ! $state['public_key_valid'] || $state['has_secret'] || $state['window_open'] ) { + throw new RuntimeException( "Step '$step': expected the enrolled key, no secret, window closed: " . wp_json_encode( $state ) ); } wpcom_migration_e2e_expect_mode( Settings_Page::MODE_PROVISIONED_WAITING, $step ); - $manual_html = wpcom_migration_e2e_render_manual( $manual ); + $_GET[ Manual_Page::NOTICE_QUERY_ARG ] = 'key_saved'; + $manual_html = wpcom_migration_e2e_render_manual( $manual ); + wpcom_migration_e2e_expect_contains( $manual_html, 'Public key saved.', $step ); + wpcom_migration_e2e_expect_contains( $manual_html, 'wpcom-migration-reprint-key-table', $step ); + wpcom_migration_e2e_expect_contains( $manual_html, '' . \WordPress\Reprint\Server\Utils::public_key_fingerprint( $public_key ) . '', $step ); + wpcom_migration_e2e_expect_contains( $manual_html, 'value="' . Manual_Page::REMOVE_PUBLIC_KEY_ACTION . '"', $step ); wpcom_migration_e2e_expect_contains( $manual_html, 'Turn the exporter on', $step ); wpcom_migration_e2e_expect_contains( $manual_html, 'wpcom-migration-reprint-api-url', $step ); - wpcom_migration_e2e_expect_contains( $manual_html, 'value="' . Manual_Page::DISCARD_SECRET_ACTION . '"', $step ); - wpcom_migration_e2e_expect_contains( $manual_html, 'Remove public key', $step ); + wpcom_migration_e2e_expect_not_contains( $manual_html, 'Remove secret', $step ); + break; + + case 'remove-public-key': + wpcom_migration_e2e_post( Manual_Page::REMOVE_PUBLIC_KEY_ACTION, array() ); + $manual->handle_remove_public_key(); + break; + + case 'assert-public-key-removed': + $state = Exporter::get_state(); + if ( $state['has_public_key'] || $state['window_open'] ) { + throw new RuntimeException( "Step '$step': removing the key should delete it and close the window: " . wp_json_encode( $state ) ); + } + $_GET[ Manual_Page::NOTICE_QUERY_ARG ] = 'key_removed'; + $manual_html = wpcom_migration_e2e_render_manual( $manual ); + wpcom_migration_e2e_expect_contains( $manual_html, 'Public key removed.', $step ); + wpcom_migration_e2e_expect_not_contains( $manual_html, 'wpcom-migration-reprint-key-table', $step ); + break; + + case 'assert-secret-removed-key-kept': + $state = Exporter::get_state(); + if ( $state['has_secret'] || ! $state['public_key_valid'] || $state['window_open'] ) { + throw new RuntimeException( "Step '$step': removing the secret should keep the key and close the window: " . wp_json_encode( $state ) ); + } break; case 'invalidate-public-key': From 75ef524c9567e90983a915181d2bcdad2310d135 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 8 Oct 2026 10:11:26 +0200 Subject: [PATCH 09/11] Center the key id vertically in the manual screen's key table Co-Authored-By: Claude Opus 5.5 --- plugin/reprint/class-manual-page.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php index 11d46d1c..72c9a88a 100644 --- a/plugin/reprint/class-manual-page.php +++ b/plugin/reprint/class-manual-page.php @@ -202,9 +202,15 @@ public function enqueue_assets( $hook_suffix ) { ); // Descriptions get a paragraph's margin, not wp-admin's tighter one. + // List tables align cells to the top, which leaves the key id sitting + // above the Remove button beside it. wp_register_style( self::STYLE_HANDLE, false, array(), $version ); wp_enqueue_style( self::STYLE_HANDLE ); - wp_add_inline_style( self::STYLE_HANDLE, '.wpcom-migration-manual p.description { margin: 1em 0; }' ); + wp_add_inline_style( + self::STYLE_HANDLE, + '.wpcom-migration-manual p.description { margin: 1em 0; }' + . ' .wpcom-migration-reprint-key-table td { vertical-align: middle; }' + ); } /** From 5dc7cc4117f10f2446c101b689d66a3b63a76c85 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 8 Oct 2026 10:11:54 +0200 Subject: [PATCH 10/11] Make Enroll key the manual screen's primary button for its form Co-Authored-By: Claude Opus 5.5 --- plugin/reprint/class-manual-page.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php index 72c9a88a..34785cf4 100644 --- a/plugin/reprint/class-manual-page.php +++ b/plugin/reprint/class-manual-page.php @@ -478,7 +478,7 @@ private function render_public_key_section( array $state ) {

-

+

Date: Thu, 8 Oct 2026 11:02:46 +0200 Subject: [PATCH 11/11] Tell the Reprint client why a signature was refused Export errors now carry Reprint's reason code, and not_configured answers with a 503, as the Reprint Server plugin does. Without the reason, the client blamed an outdated plugin for an unknown key. Co-Authored-By: Claude Opus 5.5 --- plugin/reprint/class-exporter.php | 60 ++++++++++++++++++++----------- tests/e2e/request.php | 23 +++++++++--- 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/plugin/reprint/class-exporter.php b/plugin/reprint/class-exporter.php index e6095f6a..c70921d3 100644 --- a/plugin/reprint/class-exporter.php +++ b/plugin/reprint/class-exporter.php @@ -492,12 +492,15 @@ public function handle_request() { $public_keys[ Utils::public_key_fingerprint( $public_key ) ] = $public_key; } - $auth_error = $this->verify_signature( $secret, $public_keys ); - if ( null !== $auth_error ) { + $auth_failure = $this->verify_signature( $secret, $public_keys ); + if ( null !== $auth_failure ) { if ( ! $window_open ) { return; } - $this->error( 403, $auth_error ); + // Reprint's client maps the reason to its message, and reads + // not_configured as an answer only from a 503. + $status = RequestAuthenticator::REASON_NOT_CONFIGURED === $auth_failure['reason'] ? 503 : 403; + $this->error( $status, $auth_failure['message'], $auth_failure['reason'] ); return; } @@ -548,11 +551,24 @@ protected function requested_endpoint() { * * @param string|null $secret The valid shared secret, or null. * @param array $public_keys Valid public keys by key id. - * @return string|null Error message on failure, null on success. + * @return array|null { + * Null on success, otherwise the failure. + * + * @type string $message Error description. + * @type string $reason Reprint's reason code, e.g. unknown_key or not_configured. + * } */ protected function verify_signature( $secret, array $public_keys ) { $authenticator = new RequestAuthenticator( $secret, $public_keys, self::CLOCK_SKEW ); - return $authenticator->verify_globals(); + $error = $authenticator->verify_globals(); + if ( null === $error ) { + return null; + } + + return array( + 'message' => $error, + 'reason' => $authenticator->last_error_reason() ?? RequestAuthenticator::REASON_AUTH_FAILED, + ); } /** @@ -586,17 +602,25 @@ protected function send_cors_headers() { /** * Sends a JSON error response and terminates. * - * @param int $code HTTP status code. - * @param string $message Error description. + * @param int $code HTTP status code. + * @param string $message Error description. + * @param string|null $reason Machine-readable reason code, or null for none. */ - protected function error( $code, $message ) { - self::record_event( - 'export_refused', - array( - 'code' => $code, - 'reason' => $message, - ) + protected function error( $code, $message, $reason = null ) { + $context = array( + 'code' => $code, + 'reason' => $message, + ); + $body = array( + 'error' => $message, + 'code' => $code, ); + if ( null !== $reason ) { + $context['reason_code'] = $reason; + $body['reason'] = $reason; + } + + self::record_event( 'export_refused', $context ); $this->send_cors_headers(); if ( ! headers_sent() ) { @@ -604,13 +628,7 @@ protected function error( $code, $message ) { header( 'Content-Type: application/json' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode - echo json_encode( - array( - 'error' => $message, - 'code' => $code, - ), - JSON_FORCE_OBJECT - ); + echo json_encode( $body, JSON_FORCE_OBJECT ); $this->terminate(); } diff --git a/tests/e2e/request.php b/tests/e2e/request.php index cbd42981..5d3fd986 100644 --- a/tests/e2e/request.php +++ b/tests/e2e/request.php @@ -258,11 +258,19 @@ wpcom_migration_e2e_fail( 'A PEM install returned the wrong key_id (want ' . $second_client->get_key_id() . '): ' . $response['body'] ); } + // The retired key is unknown now, and says so in the code the client + // maps to its message. $response = wpcom_migration_e2e_request( $wpcom_migration_endpoint, wpcom_migration_e2e_key_signed_headers( $key_client, $wpcom_migration_endpoint ) ); wpcom_migration_e2e_expect_status( $response, 403 ); - wpcom_migration_e2e_expect_error_json( $response, 403 ); + wpcom_migration_e2e_expect_error_json( $response, 403, 'unknown_key' ); wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, wpcom_migration_e2e_key_signed_headers( $second_client, $wpcom_migration_endpoint ) ); + + // A secret-signed request on a site with only a key: no secret is + // configured, which the client reads from a 503 not_configured. + $response = wpcom_migration_e2e_request( $wpcom_migration_endpoint, wpcom_migration_e2e_signed_headers( $wpcom_migration_secret ) ); + wpcom_migration_e2e_expect_status( $response, 503 ); + wpcom_migration_e2e_expect_error_json( $response, 503, 'not_configured' ); break; case 'connection': @@ -464,16 +472,21 @@ function wpcom_migration_e2e_expect_json( array $response ) { } /** - * Fails unless the body is the exporter's error JSON with the given code. + * Fails unless the body is the exporter's error JSON with the given code, + * and the given reason when one is named. * - * @param array $response Response from wpcom_migration_e2e_request(). - * @param int $code Expected 'code' value. + * @param array $response Response from wpcom_migration_e2e_request(). + * @param int $code Expected 'code' value. + * @param string|null $reason Expected 'reason' value, or null to skip the check. */ -function wpcom_migration_e2e_expect_error_json( array $response, $code ) { +function wpcom_migration_e2e_expect_error_json( array $response, $code, $reason = null ) { $json = wpcom_migration_e2e_expect_json( $response ); if ( ! isset( $json['code'], $json['error'] ) || $code !== (int) $json['code'] ) { wpcom_migration_e2e_fail( sprintf( 'Expected error JSON with code %d, got: %s', $code, $response['body'] ) ); } + if ( null !== $reason && ( ! isset( $json['reason'] ) || $reason !== $json['reason'] ) ) { + wpcom_migration_e2e_fail( sprintf( 'Expected error JSON with reason %s, got: %s', $reason, $response['body'] ) ); + } } /**