|
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