Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.36% covered (warning)
86.36%
114 / 132
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
REST_Controller
87.69% covered (warning)
87.69%
114 / 130
33.33% covered (danger)
33.33%
2 / 6
18.60
0.00% covered (danger)
0.00%
0 / 1
 register_routes
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
1
 export_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enable_export
78.26% covered (warning)
78.26%
18 / 23
0.00% covered (danger)
0.00%
0 / 1
4.16
 rotate_secret
77.27% covered (warning)
77.27%
17 / 22
0.00% covered (danger)
0.00%
0 / 1
3.11
 install_public_key
85.71% covered (warning)
85.71%
30 / 35
0.00% covered (danger)
0.00%
0 / 1
4.05
 permission_check
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
1<?php
2/**
3 * REST controller for Jetpack Reprint export provisioning endpoints.
4 *
5 * Requires a verified Jetpack user token for a site administrator.
6 *
7 * @package automattic/jetpack
8 */
9
10namespace Automattic\Jetpack\Reprint_Export;
11
12use Automattic\Jetpack\Connection\Rest_Authentication;
13use InvalidArgumentException;
14use WordPress\Reprint\Server\PublicKeyServer;
15use WordPress\Reprint\Server\Utils;
16use WP_Error;
17use WP_REST_Controller;
18use WP_REST_Request;
19use WP_REST_Response;
20use WP_REST_Server;
21
22if ( ! defined( 'ABSPATH' ) ) {
23    exit( 0 );
24}
25
26/**
27 * Reprint exporter REST controller.
28 */
29class REST_Controller extends WP_REST_Controller {
30
31    /**
32     * The API namespace.
33     *
34     * @var string
35     */
36    protected $namespace = 'jetpack/v4';
37
38    /**
39     * The REST base path.
40     *
41     * @var string
42     */
43    protected $rest_base = 'reprint';
44
45    /**
46     * Registers the reprint export routes.
47     */
48    public function register_routes() {
49        register_rest_route(
50            $this->namespace,
51            '/' . $this->rest_base . '/rotate-export-secret',
52            array(
53                array(
54                    'methods'             => WP_REST_Server::CREATABLE,
55                    'callback'            => array( $this, 'rotate_secret' ),
56                    'permission_callback' => array( $this, 'permission_check' ),
57                ),
58            )
59        );
60
61        register_rest_route(
62            $this->namespace,
63            '/' . $this->rest_base . '/install-public-key',
64            array(
65                array(
66                    'methods'             => WP_REST_Server::CREATABLE,
67                    'callback'            => array( $this, 'install_public_key' ),
68                    'permission_callback' => array( $this, 'permission_check' ),
69                    'args'                => array(
70                        'public_key' => array(
71                            'description' => __( 'RSA public key, as PEM or one line of base64.', 'jetpack' ),
72                            'type'        => 'string',
73                            'required'    => true,
74                            'maxLength'   => 4096,
75                        ),
76                    ),
77                ),
78            )
79        );
80
81        register_rest_route(
82            $this->namespace,
83            '/' . $this->rest_base . '/enable-export',
84            array(
85                array(
86                    'methods'             => WP_REST_Server::CREATABLE,
87                    'callback'            => array( $this, 'enable_export' ),
88                    'permission_callback' => array( $this, 'permission_check' ),
89                ),
90            )
91        );
92    }
93
94    /**
95     * The export endpoint, so callers need not know the query-arg convention.
96     *
97     * @return string
98     */
99    public function export_url() {
100        return home_url( '/?reprint-api-jetpack' );
101    }
102
103    /**
104     * Opens the export window without changing the credential, so a client can reopen one that closed.
105     *
106     * Registered only where the feature is available, so clients also use a 404
107     * from here to tell whether the site supports export at all.
108     *
109     * @return WP_REST_Response|WP_Error The unix time the window opened at, or a 409 or 500.
110     */
111    public function enable_export() {
112        if ( ! Reprint_Exporter::get_state()['has_credential'] ) {
113            return new WP_Error(
114                'jetpack_reprint_no_credential',
115                Utils::key_auth_required()
116                    ? __( 'Install a public key first.', 'jetpack' )
117                    : __( 'Rotate the export secret first.', 'jetpack' ),
118                array( 'status' => 409 )
119            );
120        }
121
122        $enabled_at = Reprint_Exporter::open_export_window();
123
124        if ( ! Reprint_Exporter::is_export_window_open() ) {
125            return new WP_Error(
126                'jetpack_reprint_window_not_opened',
127                __( 'Failed to open the export window.', 'jetpack' ),
128                array( 'status' => 500 )
129            );
130        }
131
132        Reprint_Exporter::record_event( 'window_opened', array( 'user_id' => get_current_user_id() ) );
133
134        return new WP_REST_Response(
135            array(
136                'enabled_at' => $enabled_at,
137                'export_url' => $this->export_url(),
138            ),
139            200
140        );
141    }
142
143    /**
144     * Rotates the shared secret and returns it.
145     *
146     * Uses random_bytes() rather than wp_generate_password(): sites can filter that
147     * through `random_password`, an extension point a credential should not have.
148     *
149     * @return WP_REST_Response|WP_Error The new secret, or a 500 or 501.
150     */
151    public function rotate_secret() {
152        // Where OpenSSL can verify keys, Reprint refuses secrets, so one issued here would go unused.
153        if ( Utils::key_auth_required() ) {
154            return new WP_Error(
155                'jetpack_reprint_secret_auth_unsupported',
156                __( 'This site verifies exports with public keys. Install a public key instead.', 'jetpack' ),
157                array( 'status' => 501 )
158            );
159        }
160
161        $secret = bin2hex( random_bytes( 32 ) );
162
163        if ( ! Reprint_Exporter::store_secret( $secret ) ) {
164            return new WP_Error(
165                'jetpack_reprint_secret_not_stored',
166                __( 'Failed to persist the new secret.', 'jetpack' ),
167                array( 'status' => 500 )
168            );
169        }
170
171        // A leftover public key would revive if OpenSSL availability changed.
172        delete_option( Reprint_Exporter::PUBLIC_KEY_OPTION );
173
174        Reprint_Exporter::record_event( 'secret_rotated', array( 'user_id' => get_current_user_id() ) );
175
176        return new WP_REST_Response(
177            array(
178                'secret'     => $secret,
179                'export_url' => $this->export_url(),
180            ),
181            200
182        );
183    }
184
185    /**
186     * Replaces the enrolled public key and returns its key id.
187     *
188     * Reprint validates and canonicalizes the key, so the id matches the one the client derives.
189     *
190     * @param WP_REST_Request $request The request, carrying public_key.
191     * @return WP_REST_Response|WP_Error The key id, or a 400, 500 or 501.
192     */
193    public function install_public_key( WP_REST_Request $request ) {
194        if ( ! Utils::key_auth_required() ) {
195            return new WP_Error(
196                'jetpack_reprint_key_auth_unsupported',
197                __( 'This site cannot verify public keys. Rotate the export secret instead.', 'jetpack' ),
198                array( 'status' => 501 )
199            );
200        }
201
202        try {
203            $public_key = PublicKeyServer::assert_valid_public_key( (string) $request['public_key'] );
204        } catch ( InvalidArgumentException $exception ) {
205            return new WP_Error(
206                'jetpack_reprint_invalid_public_key',
207                $exception->getMessage(),
208                array( 'status' => 400 )
209            );
210        }
211
212        if ( ! Reprint_Exporter::store_public_key( $public_key ) ) {
213            return new WP_Error(
214                'jetpack_reprint_public_key_not_stored',
215                __( 'Failed to persist the public key.', 'jetpack' ),
216                array( 'status' => 500 )
217            );
218        }
219
220        delete_option( Reprint_Exporter::SECRET_OPTION );
221
222        $key_id = Utils::public_key_fingerprint( $public_key );
223
224        Reprint_Exporter::record_event(
225            'public_key_installed',
226            array(
227                'user_id' => get_current_user_id(),
228                'key_id'  => $key_id,
229            )
230        );
231
232        return new WP_REST_Response(
233            array(
234                'key_id'     => $key_id,
235                'export_url' => $this->export_url(),
236            ),
237            200
238        );
239    }
240
241    /**
242     * Permission callback: a Jetpack-signed request from a site administrator.
243     *
244     * Deliberately a role check, not a capability one. This installs a credential
245     * that streams the whole database and file tree, and no capability says
246     * that — `manage_options` is the closest, but plugins grant it to shop
247     * managers and the like.
248     *
249     * @return bool
250     */
251    public function permission_check() {
252        if ( ! Rest_Authentication::is_signed_with_user_token() ) {
253            return false;
254        }
255
256        $user = wp_get_current_user();
257        if ( ! $user || ! $user->exists() ) {
258            return false;
259        }
260
261        // Network administrator only: the export takes every table and everything
262        // under ABSPATH, so a subsite administrator would leave with every other
263        // site's users, content and uploads.
264        if ( is_multisite() ) {
265            return is_super_admin( $user->ID );
266        }
267
268        return in_array( 'administrator', $user->roles, true );
269    }
270}