|
13 | 13 | namespace chillerlan\Authenticator\Authenticators;
|
14 | 14 |
|
15 | 15 | use chillerlan\Authenticator\Common\Hex;
|
16 |
| -use InvalidArgumentException; |
17 | 16 | use RuntimeException;
|
18 |
| -use function array_reverse; |
19 |
| -use function array_unshift; |
20 |
| -use function curl_close; |
21 |
| -use function curl_exec; |
22 |
| -use function curl_getinfo; |
23 |
| -use function curl_init; |
24 |
| -use function curl_setopt_array; |
25 |
| -use function floor; |
26 |
| -use function gmp_cmp; |
27 |
| -use function gmp_div; |
28 |
| -use function gmp_import; |
29 |
| -use function gmp_init; |
30 |
| -use function gmp_intval; |
31 |
| -use function gmp_mod; |
32 |
| -use function gmp_powm; |
33 |
| -use function hash_hmac; |
34 |
| -use function hexdec; |
35 |
| -use function implode; |
36 |
| -use function in_array; |
37 |
| -use function pack; |
38 |
| -use function preg_match; |
39 |
| -use function random_bytes; |
40 |
| -use function sha1; |
41 |
| -use function sprintf; |
42 | 17 | use function str_pad;
|
43 |
| -use function str_replace; |
44 |
| -use function str_split; |
45 |
| -use function strlen; |
46 |
| -use function strtoupper; |
47 |
| -use function substr; |
48 |
| -use function time; |
49 |
| -use function trim; |
50 |
| -use function unpack; |
51 |
| -use const CURLOPT_HTTP_VERSION; |
52 |
| -use const CURLOPT_HTTPHEADER; |
53 |
| -use const CURLOPT_POST; |
54 |
| -use const CURLOPT_POSTFIELDS; |
55 |
| -use const CURLOPT_RETURNTRANSFER; |
56 | 18 | use const STR_PAD_LEFT;
|
57 | 19 |
|
58 | 20 | /**
|
59 | 21 | * @see https://github.com/winauth/winauth/blob/master/Authenticator/BattleNetAuthenticator.cs
|
60 | 22 | * @see https://github.com/krtek4/php-bma
|
| 23 | + * @see https://github.com/jleclanche/python-bna/issues/38 |
61 | 24 | */
|
62 | 25 | final class BattleNet extends TOTP{
|
63 | 26 |
|
64 |
| - /** |
65 |
| - * @var array |
66 |
| - */ |
67 |
| - private const regions = ['EU', 'KR', 'US']; // 'CN', |
68 |
| - |
69 |
| - /** |
70 |
| - * HTTPS requests with HTTP version 1.1 only! |
71 |
| - * |
72 |
| - * @var array |
73 |
| - */ |
74 |
| - private const servers = [ |
75 |
| -# 'CN' => 'https://mobile-service.battlenet.com.cn', // ??? |
76 |
| - 'EU' => 'https://eu.mobile-service.blizzard.com', |
77 |
| - 'KR' => 'https://kr.mobile-service.blizzard.com', |
78 |
| - 'US' => 'https://us.mobile-service.blizzard.com', |
79 |
| - ]; |
80 |
| - |
81 |
| - /** |
82 |
| - * @var array |
83 |
| - */ |
84 |
| - private const endpoints = [ |
85 |
| - 'public_key' => '/enrollment/initiatePaperRestore.htm', |
86 |
| - 'validate' => '/enrollment/validatePaperRestore.htm', |
87 |
| - 'create' => '/enrollment/enroll.htm', |
88 |
| - 'servertime' => '/enrollment/time.htm', |
89 |
| - ]; |
90 |
| - |
91 |
| - private const rsa_exp_base10 = '257'; |
92 |
| - private const rsa_mod_base10 = '1048900188079865568740077109142054431570301596680341971861256789'. |
93 |
| - '6028747089429083053061828494311840511089632283544909943323209315'. |
94 |
| - '1168250152146023319326491587651685252774820340995950744075665455'. |
95 |
| - '6817606521365764930287339148921667008991098362911808810630974611'. |
96 |
| - '75643998356321993663868233366705340758102567742483097'; |
97 |
| - |
98 |
| -# private const rsa_exp_base16 = '0101'; |
99 |
| -# private const rsa_mod_base16 = '955e4bd989f3917d2f15544a7e0504eb9d7bb66b6f8a2fe470e453c779200e5e'. |
100 |
| -# '3ad2e43a02d06c4adbd8d328f1a426b83658e88bfd949b2af4eaf30054673a14'. |
101 |
| -# '19a250fa4cc1278d12855b5b25818d162c6e6ee2ab4a350d401d78f6ddb99711'. |
102 |
| -# 'e72626b48bd8b5b0b7f3acf9ea3c9e0005fee59e19136cdb7c83f2ab8b0a2a99'; |
103 |
| - |
104 |
| - /** @var array */ |
105 |
| - private $curlInfo = []; |
106 |
| - |
107 | 27 | /**
|
108 | 28 | * @inheritDoc
|
109 | 29 | */
|
@@ -163,264 +83,4 @@ public function getOTP(int $code):string{
|
163 | 83 | return str_pad((string)$code, 8, '0', STR_PAD_LEFT);
|
164 | 84 | }
|
165 | 85 |
|
166 |
| - /** |
167 |
| - * @inheritDoc |
168 |
| - */ |
169 |
| - public function getServerTime():int{ |
170 |
| - |
171 |
| - if($this->options->forceTimeRefresh === false && $this->serverTime !== 0){ |
172 |
| - return $this->getAdjustedTime($this->serverTime, $this->lastRequestTime); |
173 |
| - } |
174 |
| - |
175 |
| - $servertime = $this->request('servertime', 'US'); |
176 |
| - |
177 |
| - $this->setServertime($servertime); |
178 |
| - |
179 |
| - return $this->getAdjustedTime($this->serverTime, $this->lastRequestTime); |
180 |
| - } |
181 |
| - |
182 |
| - /** |
183 |
| - * Retrieves the secret from Battle.net using the given serial and restore code. |
184 |
| - * If the public key for the serial is given (from a previous retrieval), it saves a server request. |
185 |
| - */ |
186 |
| - public function restoreSecret(string $serial, string $restore_code, string $public_key = null):array{ |
187 |
| - $serial = $this->cleanSerial($serial); |
188 |
| - $region = $this->getRegion($serial); |
189 |
| - |
190 |
| - // fetch public key if none is given |
191 |
| - $pubkey = ($public_key !== null) |
192 |
| - ? Hex::decode($public_key) |
193 |
| - : $this->request('public_key', $region, $serial); |
194 |
| - |
195 |
| - // create HMAC hash from serial and restore code |
196 |
| - $hmac_key = $this->convertRestoreCodeToByte($restore_code); |
197 |
| - $hmac = hash_hmac('sha1', $serial.$pubkey, $hmac_key, true); |
198 |
| - // encrypt and send validation request |
199 |
| - $nonce = random_bytes(20); |
200 |
| - $encrypted_secret = $this->request('validate', $region, $serial.$this->encrypt($hmac.$nonce)); |
201 |
| - $secret = $this->decrypt($encrypted_secret, $nonce); |
202 |
| - |
203 |
| - return [ |
204 |
| - 'region' => $region, |
205 |
| - 'serial' => $this->formatSerial($serial), |
206 |
| - 'restore_code' => $restore_code, |
207 |
| - 'public_key' => Hex::encode($pubkey), |
208 |
| - 'secret' => Hex::encode($secret), |
209 |
| - ]; |
210 |
| - } |
211 |
| - |
212 |
| - /** |
213 |
| - * Creates a new authenticator that can be linked to an existing Battle.net account |
214 |
| - */ |
215 |
| - public function createAuthenticator(string $region, string $device = null):array{ |
216 |
| - $region = $this->getRegion($region); |
217 |
| - $device = str_pad(($device ?? 'BlackBerry Pearl'), 16, "\x00"); |
218 |
| - $nonce = random_bytes(37); |
219 |
| - $response = $this->request('create', $region, $this->encrypt("\x01".$nonce.$region.$device)); |
220 |
| - // timestamp, first 8 bytes of the response |
221 |
| - $this->setServertime(substr($response, 0, 8)); |
222 |
| - // decrypt rest of the response (37 bytes) |
223 |
| - $data = $this->decrypt(substr($response, 8), $nonce); |
224 |
| - // secret, first 20 bytes |
225 |
| - $secret = substr($data, 0, 20); |
226 |
| - // serial, last 17 bytes |
227 |
| - $serial = $this->cleanSerial(substr($data, 20)); |
228 |
| - // the restore code is taken from the last 10 bytes of a SHA1 hashed serial and (binary) secret |
229 |
| - $restore_code = substr(sha1($serial.$secret, true), -10); |
230 |
| - |
231 |
| - // feed the result into the restore function to verify the restore code and fetch the public key |
232 |
| - return $this->restoreSecret($serial, $this->convertRestoreCodeToChar($restore_code)); |
233 |
| - } |
234 |
| - |
235 |
| - /** |
236 |
| - * |
237 |
| - */ |
238 |
| - private function setServertime(string $encodedTimestamp):void{ |
239 |
| - $this->serverTime = (int)floor(hexdec(Hex::encode($encodedTimestamp)) / 1000); |
240 |
| - $this->lastRequestTime = (time() - (int)floor($this->curlInfo['total_time'])); |
241 |
| - } |
242 |
| - |
243 |
| - /** |
244 |
| - * @throws \RuntimeException |
245 |
| - */ |
246 |
| - private function getRegion(string $serial):string{ |
247 |
| - $region = substr(strtoupper($serial), 0, 2); |
248 |
| - |
249 |
| - if(!in_array($region, self::regions)){ |
250 |
| - throw new RuntimeException('invalid region in serial number detected'); |
251 |
| - } |
252 |
| - |
253 |
| - return $region; |
254 |
| - } |
255 |
| - |
256 |
| - /** |
257 |
| - * cleans the given serial in (EU-1111-2222-3333) and strips hyphens (EU111122223333) for use in API requests |
258 |
| - * |
259 |
| - * @throws \InvalidArgumentException |
260 |
| - */ |
261 |
| - private function cleanSerial(string $serial):string{ |
262 |
| - $serial = str_replace('-', '', strtoupper(trim($serial))); |
263 |
| - |
264 |
| - if(!preg_match('/^[CNEUSKR]{2}\d{12}$/', $serial)){ |
265 |
| - throw new InvalidArgumentException('invalid serial'); |
266 |
| - } |
267 |
| - |
268 |
| - return $serial; |
269 |
| - } |
270 |
| - |
271 |
| - /** |
272 |
| - * |
273 |
| - */ |
274 |
| - private function formatSerial(string $serial):string{ |
275 |
| - $serial = $this->cleanSerial($serial); |
276 |
| - // split the numeric part into 3x 4 numbers |
277 |
| - $blocks = str_split(substr($serial, 2), 4); |
278 |
| - // prepend the region |
279 |
| - array_unshift($blocks, substr($serial, 0, 2)); |
280 |
| - |
281 |
| - return implode('-', $blocks); |
282 |
| - } |
283 |
| - |
284 |
| - /** |
285 |
| - * @throws \RuntimeException |
286 |
| - */ |
287 |
| - private function request(string $endpoint, string $region, string $data = null):string{ |
288 |
| - |
289 |
| - $options = [ |
290 |
| - CURLOPT_RETURNTRANSFER => true, |
291 |
| - CURLOPT_HTTP_VERSION => '1.1', // we need to force http 1.1, h2 will return a HTTP/600 error (???) from Battle.net |
292 |
| - CURLOPT_HTTPHEADER => [sprintf('User-Agent: %s', $this::userAgent)], |
293 |
| - ]; |
294 |
| - |
295 |
| - if($data !== null){ |
296 |
| - $options[CURLOPT_POST] = true; |
297 |
| - $options[CURLOPT_POSTFIELDS] = $data; |
298 |
| - $options[CURLOPT_HTTPHEADER][] = 'Content-Type: application/octet-stream'; |
299 |
| - } |
300 |
| - |
301 |
| - $ch = curl_init(self::servers[$region].self::endpoints[$endpoint]); |
302 |
| - |
303 |
| - curl_setopt_array($ch, $options); |
304 |
| - |
305 |
| - $response = curl_exec($ch); |
306 |
| - $this->curlInfo = curl_getinfo($ch); |
307 |
| - |
308 |
| - curl_close($ch); |
309 |
| - |
310 |
| - if($this->curlInfo['http_code'] !== 200){ |
311 |
| - // I'm not going to investigate the error further as this shouldn't happen usually |
312 |
| - throw new RuntimeException(sprintf('Battle.net API request error: HTTP/%s', $this->curlInfo['http_code'])); // @codeCoverageIgnore |
313 |
| - } |
314 |
| - |
315 |
| - return $response; |
316 |
| - } |
317 |
| - |
318 |
| - /** |
319 |
| - * Convert restore code char to byte but with appropriate mapping to exclude I,L,O and S. |
320 |
| - * e.g. A=10 but J=18 not 19 (as I is missing) |
321 |
| - */ |
322 |
| - private function convertRestoreCodeToByte(string $restore_code):string{ |
323 |
| - $chars = unpack('C*', $restore_code); |
324 |
| - |
325 |
| - foreach($chars as &$c){ |
326 |
| - if($c > 47 && $c < 58){ |
327 |
| - $c -= 48; |
328 |
| - } |
329 |
| - else{ |
330 |
| - // S |
331 |
| - if($c > 82){ |
332 |
| - $c--; |
333 |
| - } |
334 |
| - // O |
335 |
| - if($c > 78){ |
336 |
| - $c--; |
337 |
| - } |
338 |
| - // L |
339 |
| - if($c > 75){ |
340 |
| - $c--; |
341 |
| - } |
342 |
| - // I |
343 |
| - if($c > 72){ |
344 |
| - $c--; |
345 |
| - } |
346 |
| - |
347 |
| - $c -= 55; |
348 |
| - } |
349 |
| - |
350 |
| - } |
351 |
| - |
352 |
| - return pack('C*', ...$chars); |
353 |
| - } |
354 |
| - |
355 |
| - /** |
356 |
| - * Convert restore code byte to char but with appropriate mapping to exclude I,L,O and S. |
357 |
| - */ |
358 |
| - private function convertRestoreCodeToChar(string $data):string{ |
359 |
| - $chars = unpack('C*', $data); |
360 |
| - |
361 |
| - foreach($chars as &$c){ |
362 |
| - $c &= 0x1F; |
363 |
| - |
364 |
| - if($c < 10){ |
365 |
| - $c += 48; |
366 |
| - } |
367 |
| - else{ |
368 |
| - $c += 55; |
369 |
| - // I |
370 |
| - if($c > 72){ |
371 |
| - $c++; |
372 |
| - } |
373 |
| - // L |
374 |
| - if($c > 75){ |
375 |
| - $c++; |
376 |
| - } |
377 |
| - // O |
378 |
| - if($c > 78){ |
379 |
| - $c++; |
380 |
| - } |
381 |
| - // S |
382 |
| - if($c > 82){ |
383 |
| - $c++; |
384 |
| - } |
385 |
| - } |
386 |
| - } |
387 |
| - |
388 |
| - return pack('C*', ...$chars); |
389 |
| - } |
390 |
| - |
391 |
| - /** |
392 |
| - * |
393 |
| - */ |
394 |
| - private function encrypt(string $data):string{ |
395 |
| - $num = gmp_powm(gmp_import($data), self::rsa_exp_base10, self::rsa_mod_base10); // gmp_init(self::rsa_mod_base16, 16) |
396 |
| - $zero = gmp_init('0', 10); |
397 |
| - $ret = []; |
398 |
| - |
399 |
| - while(gmp_cmp($num, $zero) > 0){ |
400 |
| - $ret[] = gmp_intval(gmp_mod($num, 256)); |
401 |
| - $num = gmp_div($num, 256); |
402 |
| - } |
403 |
| - |
404 |
| - return pack('C*', ...array_reverse($ret)); |
405 |
| - } |
406 |
| - |
407 |
| - /** |
408 |
| - * @throws \RuntimeException |
409 |
| - */ |
410 |
| - private function decrypt(string $data, string $key):string{ |
411 |
| - |
412 |
| - if(strlen($data) !== strlen($key)){ |
413 |
| - throw new RuntimeException('The decryption key size and data size doesn\'t match'); |
414 |
| - } |
415 |
| - |
416 |
| - $data = unpack('C*', $data); |
417 |
| - $key = unpack('C*', $key); |
418 |
| - |
419 |
| - foreach($data as $i => &$c){ |
420 |
| - $c ^= $key[$i]; |
421 |
| - } |
422 |
| - |
423 |
| - return pack('C*', ...$data); |
424 |
| - } |
425 |
| - |
426 | 86 | }
|
0 commit comments