Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
92.98% |
53 / 57 |
|
77.78% |
7 / 9 |
CRAP | |
0.00% |
0 / 1 |
TokenManager | |
92.98% |
53 / 57 |
|
77.78% |
7 / 9 |
15.08 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
encode | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
encrypt | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
decode | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
decrypt | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
2.00 | |||
getInitializationVector | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCipherMethod | |
62.50% |
5 / 8 |
|
0.00% |
0 / 1 |
4.84 | |||
getSessionKey | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
getSigningKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\CheckUser\Services; |
4 | |
5 | use Firebase\JWT\JWT; |
6 | use Firebase\JWT\Key; |
7 | use FormatJson; |
8 | use MediaWiki\Config\ConfigException; |
9 | use MediaWiki\Session\Session; |
10 | use MediaWiki\Utils\MWTimestamp; |
11 | use RuntimeException; |
12 | |
13 | class 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 | } |