Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
46.38% covered (danger)
46.38%
64 / 138
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Secrets
46.38% covered (danger)
46.38%
64 / 138
50.00% covered (danger)
50.00%
3 / 6
158.67
0.00% covered (danger)
0.00%
0 / 1
 delete_all
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 secret_callable_method
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 generate
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
5.03
 get
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
3.18
 delete
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 verify
24.18% covered (danger)
24.18%
22 / 91
0.00% covered (danger)
0.00%
0 / 1
113.09
1<?php
2/**
3 * The Jetpack Connection Secrets class file.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8namespace Automattic\Jetpack\Connection;
9
10use Jetpack_Options;
11use WP_Error;
12
13/**
14 * The Jetpack Connection Secrets class that is used to manage secrets.
15 */
16class Secrets {
17
18    const SECRETS_MISSING            = 'secrets_missing';
19    const SECRETS_EXPIRED            = 'secrets_expired';
20    const LEGACY_SECRETS_OPTION_NAME = 'jetpack_secrets';
21
22    /**
23     * Deletes all connection secrets from the local Jetpack site.
24     */
25    public function delete_all() {
26        Jetpack_Options::delete_raw_option( 'jetpack_secrets' );
27    }
28
29    /**
30     * Runs the wp_generate_password function with the required parameters. This is the
31     * default implementation of the secret callable, can be overridden using the
32     * jetpack_connection_secret_generator filter.
33     *
34     * @return String $secret value.
35     */
36    private function secret_callable_method() {
37        $secret = wp_generate_password( 32, false );
38
39        // Some sites may hook into the random_password filter and make the password shorter, let's make sure our secret has the required length.
40        $attempts      = 1;
41        $secret_length = strlen( $secret );
42        while ( $secret_length < 32 && $attempts < 32 ) {
43            ++$attempts;
44            $secret       .= wp_generate_password( 32, false );
45            $secret_length = strlen( $secret );
46        }
47        return (string) substr( $secret, 0, 32 );
48    }
49
50    /**
51     * Generates two secret tokens and the end of life timestamp for them.
52     *
53     * @param String       $action       The action name.
54     * @param Integer|bool $user_id The user identifier. Defaults to `false`.
55     * @param Integer      $exp          Expiration time in seconds.
56     */
57    public function generate( $action, $user_id = false, $exp = 600 ) {
58        if ( false === $user_id ) {
59            $user_id = get_current_user_id();
60        }
61
62        $callable = apply_filters( 'jetpack_connection_secret_generator', array( static::class, 'secret_callable_method' ) );
63
64        $secrets = Jetpack_Options::get_raw_option(
65            self::LEGACY_SECRETS_OPTION_NAME,
66            array()
67        );
68
69        $secret_name = 'jetpack_' . $action . '_' . $user_id;
70
71        if (
72            isset( $secrets[ $secret_name ] ) &&
73            $secrets[ $secret_name ]['exp'] > time()
74        ) {
75            return $secrets[ $secret_name ];
76        }
77
78        $secret_value = array(
79            'secret_1' => call_user_func( $callable ),
80            'secret_2' => call_user_func( $callable ),
81            'exp'      => time() + $exp,
82        );
83
84        $secrets[ $secret_name ] = $secret_value;
85
86        $res = Jetpack_Options::update_raw_option( self::LEGACY_SECRETS_OPTION_NAME, $secrets );
87        return $res ? $secrets[ $secret_name ] : false;
88    }
89
90    /**
91     * Returns two secret tokens and the end of life timestamp for them.
92     *
93     * @param String  $action  The action name.
94     * @param Integer $user_id The user identifier.
95     * @return string|array an array of secrets or an error string.
96     * @phan-return string|array{secret_1:string,secret_2:string,exp:int}
97     */
98    public function get( $action, $user_id ) {
99        $secret_name = 'jetpack_' . $action . '_' . $user_id;
100        $secrets     = Jetpack_Options::get_raw_option(
101            self::LEGACY_SECRETS_OPTION_NAME,
102            array()
103        );
104
105        if ( ! isset( $secrets[ $secret_name ] ) ) {
106            return self::SECRETS_MISSING;
107        }
108
109        if ( $secrets[ $secret_name ]['exp'] < time() ) {
110            $this->delete( $action, $user_id );
111            return self::SECRETS_EXPIRED;
112        }
113
114        return $secrets[ $secret_name ];
115    }
116
117    /**
118     * Deletes secret tokens in case they, for example, have expired.
119     *
120     * @param String  $action  The action name.
121     * @param Integer $user_id The user identifier.
122     */
123    public function delete( $action, $user_id ) {
124        $secret_name = 'jetpack_' . $action . '_' . $user_id;
125        $secrets     = Jetpack_Options::get_raw_option(
126            self::LEGACY_SECRETS_OPTION_NAME,
127            array()
128        );
129        if ( isset( $secrets[ $secret_name ] ) ) {
130            unset( $secrets[ $secret_name ] );
131            Jetpack_Options::update_raw_option( self::LEGACY_SECRETS_OPTION_NAME, $secrets );
132        }
133    }
134
135    /**
136     * Verify a Previously Generated Secret.
137     *
138     * @param string $action   The type of secret to verify.
139     * @param string $secret_1 The secret string to compare to what is stored.
140     * @param int    $user_id  The user ID of the owner of the secret.
141     * @return WP_Error|string WP_Error on failure, secret_2 on success.
142     */
143    public function verify( $action, $secret_1, $user_id ) {
144        $allowed_actions = array( 'register', 'authorize', 'publicize' );
145        if ( ! in_array( $action, $allowed_actions, true ) ) {
146            return new WP_Error( 'unknown_verification_action', 'Unknown Verification Action', 400 );
147        }
148
149        $user = get_user_by( 'id', $user_id );
150
151        /**
152         * We've begun verifying the previously generated secret.
153         *
154         * @since 1.7.0
155         * @since-jetpack 7.5.0
156         *
157         * @param string   $action The type of secret to verify.
158         * @param \WP_User $user The user object.
159         */
160        do_action( 'jetpack_verify_secrets_begin', $action, $user );
161
162        /** Closure to run the 'fail' action and return an error. */
163        $return_error = function ( WP_Error $error ) use ( $action, $user ) {
164            /**
165             * Verifying of the previously generated secret has failed.
166             *
167             * @since 1.7.0
168             * @since-jetpack 7.5.0
169             *
170             * @param string    $action  The type of secret to verify.
171             * @param \WP_User  $user The user object.
172             * @param WP_Error $error The error object.
173             */
174            do_action( 'jetpack_verify_secrets_fail', $action, $user, $error );
175
176            return $error;
177        };
178
179        $stored_secrets = $this->get( $action, $user_id );
180        $this->delete( $action, $user_id );
181
182        $error = null;
183        if ( empty( $secret_1 ) ) {
184            $error = $return_error(
185                new WP_Error(
186                    'verify_secret_1_missing',
187                    /* translators: "%s" is the name of a paramter. It can be either "secret_1" or "state". */
188                    sprintf( __( 'The required "%s" parameter is missing.', 'jetpack-connection' ), 'secret_1' ),
189                    400
190                )
191            );
192        } elseif ( ! is_string( $secret_1 ) ) {
193            $error = $return_error(
194                new WP_Error(
195                    'verify_secret_1_malformed',
196                    /* translators: "%s" is the name of a paramter. It can be either "secret_1" or "state". */
197                    sprintf( __( 'The required "%s" parameter is malformed.', 'jetpack-connection' ), 'secret_1' ),
198                    400
199                )
200            );
201        } elseif ( empty( $user_id ) ) {
202            // $user_id is passed around during registration as "state".
203            $error = $return_error(
204                new WP_Error(
205                    'state_missing',
206                    /* translators: "%s" is the name of a paramter. It can be either "secret_1" or "state". */
207                    sprintf( __( 'The required "%s" parameter is missing.', 'jetpack-connection' ), 'state' ),
208                    400
209                )
210            );
211        } elseif ( ! ctype_digit( (string) $user_id ) ) {
212            $error = $return_error(
213                new WP_Error(
214                    'state_malformed',
215                    /* translators: "%s" is the name of a paramter. It can be either "secret_1" or "state". */
216                    sprintf( __( 'The required "%s" parameter is malformed.', 'jetpack-connection' ), 'state' ),
217                    400
218                )
219            );
220        } elseif ( self::SECRETS_MISSING === $stored_secrets ) {
221            $error = $return_error(
222                new WP_Error(
223                    'verify_secrets_missing',
224                    __( 'Verification secrets not found', 'jetpack-connection' ),
225                    400
226                )
227            );
228        } elseif ( self::SECRETS_EXPIRED === $stored_secrets ) {
229            $error = $return_error(
230                new WP_Error(
231                    'verify_secrets_expired',
232                    __( 'Verification took too long', 'jetpack-connection' ),
233                    400
234                )
235            );
236        } elseif ( ! $stored_secrets ) {
237            $error = $return_error(
238                new WP_Error(
239                    'verify_secrets_empty',
240                    __( 'Verification secrets are empty', 'jetpack-connection' ),
241                    400
242                )
243            );
244        } elseif ( is_wp_error( $stored_secrets ) ) {
245            $stored_secrets->add_data( 400 );
246            $error = $return_error( $stored_secrets );
247        } elseif ( empty( $stored_secrets['secret_1'] ) || empty( $stored_secrets['secret_2'] ) || empty( $stored_secrets['exp'] ) ) {
248            $error = $return_error(
249                new WP_Error(
250                    'verify_secrets_incomplete',
251                    __( 'Verification secrets are incomplete', 'jetpack-connection' ),
252                    400
253                )
254            );
255        } elseif ( ! hash_equals( $secret_1, $stored_secrets['secret_1'] ) ) {
256            $error = $return_error(
257                new WP_Error(
258                    'verify_secrets_mismatch',
259                    __( 'Secret mismatch', 'jetpack-connection' ),
260                    400
261                )
262            );
263        }
264
265        // Something went wrong during the checks, returning the error.
266        if ( ! empty( $error ) ) {
267            return $error;
268        }
269
270        /**
271         * We've succeeded at verifying the previously generated secret.
272         *
273         * @since 1.7.0
274         * @since-jetpack 7.5.0
275         *
276         * @param string   $action The type of secret to verify.
277         * @param \WP_User $user The user object.
278         */
279        do_action( 'jetpack_verify_secrets_success', $action, $user );
280
281        return $stored_secrets['secret_2'];
282    }
283}