Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.98% covered (success)
92.98%
53 / 57
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
TokenManager
92.98% covered (success)
92.98%
53 / 57
77.78% covered (warning)
77.78%
7 / 9
15.08
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 encode
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 encrypt
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 decode
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 decrypt
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 getInitializationVector
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCipherMethod
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 getSessionKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getSigningKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\CheckUser\Services;
4
5use Firebase\JWT\JWT;
6use Firebase\JWT\Key;
7use FormatJson;
8use MediaWiki\Config\ConfigException;
9use MediaWiki\Session\Session;
10use MediaWiki\Utils\MWTimestamp;
11use RuntimeException;
12
13class TokenManager {
14    /** @var string */
15    private const SIGNING_ALGO = 'HS256';
16
17    /** @var string|null */
18    private $cipherMethod;
19
20    private string $secret;
21
22    /**
23     * @param string $secret
24     */
25    public function __construct(
26        string $secret
27    ) {
28        if ( $secret === '' ) {
29            throw new ConfigException(
30                'CheckUser Token Manager requires $wgSecretKey to be set.'
31            );
32        }
33        $this->secret = $secret;
34    }
35
36    /**
37     * Creates a token
38     *
39     * @param Session $session
40     * @param array $data
41     * @return string
42     */
43    public function encode( Session $session, array $data ): string {
44        $key = $this->getSessionKey( $session );
45        $iv = $this->getInitializationVector();
46        return JWT::encode(
47            [
48                // Expiration Time https://tools.ietf.org/html/rfc7519#section-4.1.4
49                // 24 hours from now
50                'exp' => MWTimestamp::time() + 86400,
51                'iv' => base64_encode( $iv ),
52                // Encrypt the form data to prevent it from being leaked.
53                'data' => $this->encrypt( $data, $iv ),
54            ],
55            $this->getSigningKey( $key ),
56            self::SIGNING_ALGO
57        );
58    }
59
60    /**
61     * Encrypt private data.
62     *
63     * @param mixed $input
64     * @param string $iv
65     * @return string
66     */
67    private function encrypt( $input, string $iv ): string {
68        return openssl_encrypt(
69            FormatJson::encode( $input ),
70            $this->getCipherMethod(),
71            $this->secret,
72            0,
73            $iv
74        );
75    }
76
77    /**
78     * Decode the JWT and return the targets.
79     *
80     * @param Session $session
81     * @param string $token
82     * @return array
83     */
84    public function decode( Session $session, string $token ): array {
85        $key = $this->getSessionKey( $session );
86        $payload = JWT::decode(
87            $token,
88            new Key( $this->getSigningKey( $key ), self::SIGNING_ALGO )
89        );
90
91        return $this->decrypt(
92            $payload->data,
93            base64_decode( $payload->iv )
94        );
95    }
96
97    /**
98     * Decrypt private data.
99     *
100     * @param string $input
101     * @param string $iv
102     * @return array
103     */
104    private function decrypt( string $input, string $iv ): array {
105        $decrypted = openssl_decrypt(
106            $input,
107            $this->getCipherMethod(),
108            $this->secret,
109            0,
110            $iv
111        );
112
113        if ( $decrypted === false ) {
114            throw new RuntimeException( 'Decryption Failed' );
115        }
116
117        return FormatJson::parse( $decrypted, FormatJson::FORCE_ASSOC )->getValue();
118    }
119
120    /**
121     * Get the initialization vector.
122     *
123     * This must be consistent between encryption and decryption,
124     * must be no more than 16 bytes in length and never repeat.
125     *
126     * @return string
127     */
128    private function getInitializationVector(): string {
129        return random_bytes( 16 );
130    }
131
132    /**
133     * Decide what type of encryption to use, based on system capabilities.
134     *
135     * @see Session::getEncryptionAlgorithm()
136     *
137     * @return string
138     */
139    private function getCipherMethod(): string {
140        if ( !$this->cipherMethod ) {
141            $methods = openssl_get_cipher_methods();
142            if ( in_array( 'aes-256-ctr', $methods, true ) ) {
143                $this->cipherMethod = 'aes-256-ctr';
144            } elseif ( in_array( 'aes-256-cbc', $methods, true ) ) {
145                $this->cipherMethod = 'aes-256-cbc';
146            } else {
147                throw new ConfigException( 'No valid cipher method found with openssl_get_cipher_methods()' );
148            }
149        }
150
151        return $this->cipherMethod;
152    }
153
154    /**
155     * Get the session key suitable for the signing key and initialization vector.
156     *
157     * For the initialization vector, this must be consistent between encryption and decryption
158     * and must be no more than 16 bytes in length.
159     *
160     * This is retrieved from the session or randomly generated and stored in the session. This means
161     * that a token cannot be shared between sessions.
162     *
163     * @param Session $session
164     *
165     * @return string
166     */
167    private function getSessionKey( Session $session ): string {
168        $key = $session->get( 'CheckUserTokenKey' );
169        if ( $key === null ) {
170            $key = base64_encode( random_bytes( 16 ) );
171            $session->set( 'CheckUserTokenKey', $key );
172        }
173
174        return base64_decode( $key );
175    }
176
177    /**
178     * Get the signing key.
179     *
180     * @param string $sessionKey
181     * @return string
182     */
183    private function getSigningKey( string $sessionKey ): string {
184        return hash_hmac( 'sha256', $sessionKey, $this->secret );
185    }
186}