diff --git a/README.md b/README.md index 9a1be8f8..8dfd95f0 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 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 -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. 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/plugin/reprint/class-exporter.php b/plugin/reprint/class-exporter.php index 536d19cf..c70921d3 100644 --- a/plugin/reprint/class-exporter.php +++ b/plugin/reprint/class-exporter.php @@ -1,6 +1,6 @@ get_current_user_id() ) ); + } + } + /** * Stores a shared secret together with its salt-keyed hash. * @@ -238,6 +290,28 @@ public static function store_secret( $secret ) { && self::credential_hash_matches( self::SECRET_HASH_OPTION, $secret ); } + /** + * Stores a public key together with its salt-keyed hash, replacing any + * key stored before. + * + * Takes the one-line form PublicKeyServer::assert_valid_public_key() + * returns. Saving a key touches neither the secret nor the export window. + * + * @param string $public_key The one-line public key. + * @return bool Whether the key and a matching hash are now stored. + */ + public static function store_public_key( $public_key ) { + if ( ! is_string( $public_key ) || '' === $public_key ) { + return false; + } + + self::write_option( self::PUBLIC_KEY_OPTION, $public_key ); + self::write_option( self::PUBLIC_KEY_HASH_OPTION, self::compute_credential_hash( self::PUBLIC_KEY_HASH_OPTION, $public_key ) ); + + return get_option( self::PUBLIC_KEY_OPTION ) === $public_key + && self::credential_hash_matches( self::PUBLIC_KEY_HASH_OPTION, $public_key ); + } + /** * Computes the HMAC binding a stored credential to the site's auth salt. * @@ -286,7 +360,7 @@ public static function is_export_window_open( $now = null ) { $now = null === $now ? time() : (int) $now; return $enabled_at > 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 +392,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 +445,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,34 +462,45 @@ 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 ); - if ( null !== $auth_error ) { + $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_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; } @@ -447,16 +544,31 @@ 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 authenticator. * - * Seam so a test double can skip the real server. + * @param string|null $secret The valid shared secret, or null. + * @param array $public_keys Valid public keys by key id. + * @return array|null { + * Null on success, otherwise the failure. * - * @param string $secret The shared secret. - * @return string|null Error message on failure, null on success. + * @type string $message Error description. + * @type string $reason Reprint's reason code, e.g. unknown_key or not_configured. + * } */ - 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 ); + $error = $authenticator->verify_globals(); + if ( null === $error ) { + return null; + } + + return array( + 'message' => $error, + 'reason' => $authenticator->last_error_reason() ?? RequestAuthenticator::REASON_AUTH_FAILED, + ); } /** @@ -490,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() ) { @@ -508,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/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php index 0e2ad1d1..34785cf4 100644 --- a/plugin/reprint/class-manual-page.php +++ b/plugin/reprint/class-manual-page.php @@ -1,6 +1,7 @@ authorize( self::SAVE_ENABLED_ACTION ); $state = Exporter::get_state(); - if ( ! $state['secret_valid'] ) { + if ( ! $state['credential_valid'] ) { $this->redirect_with_notice( 'not_configured' ); } @@ -234,10 +269,62 @@ public function handle_save_enabled() { public function handle_discard_secret() { $this->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 +347,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 +358,9 @@ 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. + * 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,8 +381,9 @@ 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['secret_valid'] ) { + if ( $state['credential_valid'] ) { $this->render_enable_form( $state ); $this->render_api_url(); } @@ -357,9 +449,70 @@ 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/plugin/reprint/class-rest-controller.php b/plugin/reprint/class-rest-controller.php index 18f317cd..6dd0921c 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', @@ -83,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 ) ) { @@ -107,18 +142,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 +237,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/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-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..5c11d451 --- /dev/null +++ b/tests/e2e/hmac-only-mu-plugin.php @@ -0,0 +1,13 @@ + * * Scenarios: open, closed, secret-hash-deleted, enabled-hash-deleted, screen, - * provisioning, 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 @@ -22,23 +22,25 @@ $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 ) { 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': @@ -80,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'; @@ -91,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'] ); } @@ -108,11 +111,16 @@ $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' ); 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' ); @@ -143,7 +151,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. @@ -159,7 +167,110 @@ 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 ); + + // 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 ); + 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'] ); + } + + // 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, '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': @@ -167,7 +278,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'] ); } @@ -178,7 +289,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: @@ -190,11 +301,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 ) { @@ -231,6 +342,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. @@ -259,22 +385,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 ) { @@ -343,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'] ) ); + } } /** diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 07d43f5f..a40c00f3 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-hmac provisioning-key connection menu multisite}) for command_name in php npx; do if ! command -v "$command_name" >/dev/null 2>&1; then diff --git a/tests/e2e/screen-steps.php b/tests/e2e/screen-steps.php index 11f062c5..09eb48f2 100644 --- a/tests/e2e/screen-steps.php +++ b/tests/e2e/screen-steps.php @@ -241,7 +241,7 @@ function wpcom_migration_e2e_screen_step( $step ) { case 'assert-secret-discarded': $state = Exporter::get_state(); - if ( $state['has_secret'] || $state['window_open'] ) { + if ( $state['has_secret'] || $state['has_public_key'] || $state['window_open'] ) { throw new RuntimeException( 'Removing the secret should delete it and close the window: ' . wp_json_encode( $state ) ); } wpcom_migration_e2e_expect_mode( Settings_Page::MODE_NEEDS_CONNECTING, $step ); @@ -255,6 +255,86 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_not_contains( $manual_html, 'Turn the exporter on', $step ); break; + case 'render-public-key-form': + $manual_html = wpcom_migration_e2e_render_manual( $manual ); + wpcom_migration_e2e_expect_contains( $manual_html, 'name="' . Manual_Page::PUBLIC_KEY_FIELD . '"', $step ); + wpcom_migration_e2e_expect_contains( $manual_html, 'Enroll key', $step ); + wpcom_migration_e2e_expect_not_contains( $manual_html, 'wpcom-migration-reprint-key-table', $step ); + break; + + case 'enroll-invalid-public-key': + wpcom_migration_e2e_post( Manual_Page::SAVE_PUBLIC_KEY_ACTION, array( Manual_Page::PUBLIC_KEY_FIELD => 'not a key' ) ); + $manual->handle_save_public_key(); + break; + + case 'assert-no-public-key': + $state = Exporter::get_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 ); + $_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_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': + delete_option( Exporter::PUBLIC_KEY_HASH_OPTION ); + $state = Exporter::get_state(); + if ( ! $state['has_public_key'] || $state['public_key_valid'] || $state['credential_valid'] ) { + throw new RuntimeException( "Step '$step': deleting the hash should leave the key set but invalid: " . wp_json_encode( $state ) ); + } + wpcom_migration_e2e_expect_mode( Settings_Page::MODE_BROKEN, $step ); + $html = wpcom_migration_e2e_render( $page ); + wpcom_migration_e2e_expect_contains( $html, 'no longer matches this site', $step ); + break; + case 'assert-no-mode-headings': $html = wpcom_migration_e2e_render( $page ); wpcom_migration_e2e_expect_not_contains( $html, 'wpcom-migration-mode', $step );