Skip to content

Commit 8c00b59

Browse files
committed
:octocat: remove BattleNet secret creation/restore (#6)
(cherry picked from commit 0d0f61f)
1 parent e0bf5d7 commit 8c00b59

File tree

3 files changed

+2
-375
lines changed

3 files changed

+2
-375
lines changed

examples/battlenet.php

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
use chillerlan\Authenticator\{Authenticator, AuthenticatorOptions};
12-
use chillerlan\Authenticator\Authenticators\{AuthenticatorInterface, BattleNet};
12+
use chillerlan\Authenticator\Authenticators\AuthenticatorInterface;
1313

1414
require_once '../vendor/autoload.php';
1515

@@ -34,13 +34,3 @@
3434
// allow 2 adjacent codes
3535
$options->adjacent = 2;
3636
var_dump($auth->verify($code, (time() + 2 * $options->period))); // -> true
37-
38-
// request a new authenticator from the Battle.net API
39-
// this requires the BattleNet class to be invoked directly as we're using non-interface methods for this
40-
$auth = new BattleNet;
41-
$data = $auth->createAuthenticator('EU');
42-
// the serial can be used to attach this authenticator to an existing Battle.net account
43-
var_dump($data);
44-
// it's also possible to retreive an authenticator secret from an existing serial and restore code, e.g. from WinAuth
45-
$data = $auth->restoreSecret($data['serial'], $data['restore_code']);
46-
var_dump($data);

src/Authenticators/BattleNet.php

Lines changed: 1 addition & 344 deletions
Original file line numberDiff line numberDiff line change
@@ -13,97 +13,18 @@
1313
namespace chillerlan\Authenticator\Authenticators;
1414

1515
use chillerlan\Authenticator\Common\Hex;
16-
use InvalidArgumentException;
1716
use RuntimeException;
1817
use SensitiveParameter;
19-
use function array_reverse;
20-
use function array_unshift;
21-
use function curl_close;
22-
use function curl_exec;
23-
use function curl_getinfo;
24-
use function curl_init;
25-
use function curl_setopt_array;
26-
use function floor;
27-
use function gmp_cmp;
28-
use function gmp_div;
29-
use function gmp_import;
30-
use function gmp_init;
31-
use function gmp_intval;
32-
use function gmp_mod;
33-
use function gmp_powm;
34-
use function hash_hmac;
35-
use function hexdec;
36-
use function implode;
37-
use function in_array;
38-
use function pack;
39-
use function preg_match;
40-
use function random_bytes;
41-
use function sha1;
42-
use function sprintf;
4318
use function str_pad;
44-
use function str_replace;
45-
use function str_split;
46-
use function strlen;
47-
use function strtoupper;
48-
use function substr;
49-
use function time;
50-
use function trim;
51-
use function unpack;
52-
use const CURLOPT_HTTP_VERSION;
53-
use const CURLOPT_HTTPHEADER;
54-
use const CURLOPT_POST;
55-
use const CURLOPT_POSTFIELDS;
56-
use const CURLOPT_RETURNTRANSFER;
5719
use const STR_PAD_LEFT;
5820

5921
/**
6022
* @see https://github.com/winauth/winauth/blob/master/Authenticator/BattleNetAuthenticator.cs
6123
* @see https://github.com/krtek4/php-bma
24+
* @see https://github.com/jleclanche/python-bna/issues/38
6225
*/
6326
final class BattleNet extends TOTP{
6427

65-
/**
66-
* @var array
67-
*/
68-
private const regions = ['EU', 'KR', 'US']; // 'CN',
69-
70-
/**
71-
* HTTPS requests with HTTP version 1.1 only!
72-
*
73-
* @var array
74-
*/
75-
private const servers = [
76-
# 'CN' => 'https://mobile-service.battlenet.com.cn', // ???
77-
'EU' => 'https://eu.mobile-service.blizzard.com',
78-
'KR' => 'https://kr.mobile-service.blizzard.com',
79-
'US' => 'https://us.mobile-service.blizzard.com',
80-
];
81-
82-
/**
83-
* @var array
84-
*/
85-
private const endpoints = [
86-
'public_key' => '/enrollment/initiatePaperRestore.htm',
87-
'validate' => '/enrollment/validatePaperRestore.htm',
88-
'create' => '/enrollment/enroll.htm',
89-
'servertime' => '/enrollment/time.htm',
90-
];
91-
92-
private const rsa_exp_base10 = '257';
93-
private const rsa_mod_base10 = '1048900188079865568740077109142054431570301596680341971861256789'.
94-
'6028747089429083053061828494311840511089632283544909943323209315'.
95-
'1168250152146023319326491587651685252774820340995950744075665455'.
96-
'6817606521365764930287339148921667008991098362911808810630974611'.
97-
'75643998356321993663868233366705340758102567742483097';
98-
99-
# private const rsa_exp_base16 = '0101';
100-
# private const rsa_mod_base16 = '955e4bd989f3917d2f15544a7e0504eb9d7bb66b6f8a2fe470e453c779200e5e'.
101-
# '3ad2e43a02d06c4adbd8d328f1a426b83658e88bfd949b2af4eaf30054673a14'.
102-
# '19a250fa4cc1278d12855b5b25818d162c6e6ee2ab4a350d401d78f6ddb99711'.
103-
# 'e72626b48bd8b5b0b7f3acf9ea3c9e0005fee59e19136cdb7c83f2ab8b0a2a99';
104-
105-
private array $curlInfo = [];
106-
10728
/**
10829
* @inheritDoc
10930
*/
@@ -163,268 +84,4 @@ public function getOTP(#[SensitiveParameter] int $code):string{
16384
return str_pad((string)$code, 8, '0', STR_PAD_LEFT);
16485
}
16586

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(
187-
#[SensitiveParameter] string $serial,
188-
#[SensitiveParameter] string $restore_code,
189-
#[SensitiveParameter] string $public_key = null
190-
):array{
191-
$serial = $this->cleanSerial($serial);
192-
$region = $this->getRegion($serial);
193-
194-
// fetch public key if none is given
195-
$pubkey = ($public_key !== null)
196-
? Hex::decode($public_key)
197-
: $this->request('public_key', $region, $serial);
198-
199-
// create HMAC hash from serial and restore code
200-
$hmac_key = $this->convertRestoreCodeToByte($restore_code);
201-
$hmac = hash_hmac('sha1', $serial.$pubkey, $hmac_key, true);
202-
// encrypt and send validation request
203-
$nonce = random_bytes(20);
204-
$encrypted_secret = $this->request('validate', $region, $serial.$this->encrypt($hmac.$nonce));
205-
$secret = $this->decrypt($encrypted_secret, $nonce);
206-
207-
return [
208-
'region' => $region,
209-
'serial' => $this->formatSerial($serial),
210-
'restore_code' => $restore_code,
211-
'public_key' => Hex::encode($pubkey),
212-
'secret' => Hex::encode($secret),
213-
];
214-
}
215-
216-
/**
217-
* Creates a new authenticator that can be linked to an existing Battle.net account
218-
*/
219-
public function createAuthenticator(string $region, string $device = null):array{
220-
$region = $this->getRegion($region);
221-
$device = str_pad(($device ?? 'BlackBerry Pearl'), 16, "\x00");
222-
$nonce = random_bytes(37);
223-
$response = $this->request('create', $region, $this->encrypt("\x01".$nonce.$region.$device));
224-
// timestamp, first 8 bytes of the response
225-
$this->setServertime(substr($response, 0, 8));
226-
// decrypt rest of the response (37 bytes)
227-
$data = $this->decrypt(substr($response, 8), $nonce);
228-
// secret, first 20 bytes
229-
$secret = substr($data, 0, 20);
230-
// serial, last 17 bytes
231-
$serial = $this->cleanSerial(substr($data, 20));
232-
// the restore code is taken from the last 10 bytes of a SHA1 hashed serial and (binary) secret
233-
$restore_code = substr(sha1($serial.$secret, true), -10);
234-
235-
// feed the result into the restore function to verify the restore code and fetch the public key
236-
return $this->restoreSecret($serial, $this->convertRestoreCodeToChar($restore_code));
237-
}
238-
239-
/**
240-
*
241-
*/
242-
private function setServertime(string $encodedTimestamp):void{
243-
$this->serverTime = (int)floor(hexdec(Hex::encode($encodedTimestamp)) / 1000);
244-
$this->lastRequestTime = (time() - (int)floor($this->curlInfo['total_time']));
245-
}
246-
247-
/**
248-
* @throws \RuntimeException
249-
*/
250-
private function getRegion(string $serial):string{
251-
$region = substr(strtoupper($serial), 0, 2);
252-
253-
if(!in_array($region, self::regions)){
254-
throw new RuntimeException('invalid region in serial number detected');
255-
}
256-
257-
return $region;
258-
}
259-
260-
/**
261-
* cleans the given serial in (EU-1111-2222-3333) and strips hyphens (EU111122223333) for use in API requests
262-
*
263-
* @throws \InvalidArgumentException
264-
*/
265-
private function cleanSerial(#[SensitiveParameter] string $serial):string{
266-
$serial = str_replace('-', '', strtoupper(trim($serial)));
267-
268-
if(!preg_match('/^[CNEUSKR]{2}\d{12}$/', $serial)){
269-
throw new InvalidArgumentException('invalid serial');
270-
}
271-
272-
return $serial;
273-
}
274-
275-
/**
276-
*
277-
*/
278-
private function formatSerial(#[SensitiveParameter] string $serial):string{
279-
$serial = $this->cleanSerial($serial);
280-
// split the numeric part into 3x 4 numbers
281-
$blocks = str_split(substr($serial, 2), 4);
282-
// prepend the region
283-
array_unshift($blocks, substr($serial, 0, 2));
284-
285-
return implode('-', $blocks);
286-
}
287-
288-
/**
289-
* @throws \RuntimeException
290-
*/
291-
private function request(string $endpoint, string $region, string $data = null):string{
292-
293-
$options = [
294-
CURLOPT_RETURNTRANSFER => true,
295-
CURLOPT_HTTP_VERSION => '1.1', // we need to force http 1.1, h2 will return a HTTP/600 error (???) from Battle.net
296-
CURLOPT_HTTPHEADER => [sprintf('User-Agent: %s', $this::userAgent)],
297-
];
298-
299-
if($data !== null){
300-
$options[CURLOPT_POST] = true;
301-
$options[CURLOPT_POSTFIELDS] = $data;
302-
$options[CURLOPT_HTTPHEADER][] = 'Content-Type: application/octet-stream';
303-
}
304-
305-
$ch = curl_init(self::servers[$region].self::endpoints[$endpoint]);
306-
307-
curl_setopt_array($ch, $options);
308-
309-
$response = curl_exec($ch);
310-
$this->curlInfo = curl_getinfo($ch);
311-
312-
curl_close($ch);
313-
314-
if($this->curlInfo['http_code'] !== 200){
315-
// I'm not going to investigate the error further as this shouldn't happen usually
316-
throw new RuntimeException(sprintf('Battle.net API request error: HTTP/%s', $this->curlInfo['http_code'])); // @codeCoverageIgnore
317-
}
318-
319-
return $response;
320-
}
321-
322-
/**
323-
* Convert restore code char to byte but with appropriate mapping to exclude I,L,O and S.
324-
* e.g. A=10 but J=18 not 19 (as I is missing)
325-
*/
326-
private function convertRestoreCodeToByte(#[SensitiveParameter] string $restore_code):string{
327-
$chars = unpack('C*', $restore_code);
328-
329-
foreach($chars as &$c){
330-
if($c > 47 && $c < 58){
331-
$c -= 48;
332-
}
333-
else{
334-
// S
335-
if($c > 82){
336-
$c--;
337-
}
338-
// O
339-
if($c > 78){
340-
$c--;
341-
}
342-
// L
343-
if($c > 75){
344-
$c--;
345-
}
346-
// I
347-
if($c > 72){
348-
$c--;
349-
}
350-
351-
$c -= 55;
352-
}
353-
354-
}
355-
356-
return pack('C*', ...$chars);
357-
}
358-
359-
/**
360-
* Convert restore code byte to char but with appropriate mapping to exclude I,L,O and S.
361-
*/
362-
private function convertRestoreCodeToChar(#[SensitiveParameter] string $data):string{
363-
$chars = unpack('C*', $data);
364-
365-
foreach($chars as &$c){
366-
$c &= 0x1F;
367-
368-
if($c < 10){
369-
$c += 48;
370-
}
371-
else{
372-
$c += 55;
373-
// I
374-
if($c > 72){
375-
$c++;
376-
}
377-
// L
378-
if($c > 75){
379-
$c++;
380-
}
381-
// O
382-
if($c > 78){
383-
$c++;
384-
}
385-
// S
386-
if($c > 82){
387-
$c++;
388-
}
389-
}
390-
}
391-
392-
return pack('C*', ...$chars);
393-
}
394-
395-
/**
396-
*
397-
*/
398-
private function encrypt(#[SensitiveParameter] string $data):string{
399-
$num = gmp_powm(gmp_import($data), self::rsa_exp_base10, self::rsa_mod_base10); // gmp_init(self::rsa_mod_base16, 16)
400-
$zero = gmp_init('0', 10);
401-
$ret = [];
402-
403-
while(gmp_cmp($num, $zero) > 0){
404-
$ret[] = gmp_intval(gmp_mod($num, 256));
405-
$num = gmp_div($num, 256);
406-
}
407-
408-
return pack('C*', ...array_reverse($ret));
409-
}
410-
411-
/**
412-
* @throws \RuntimeException
413-
*/
414-
private function decrypt(#[SensitiveParameter] string $data, #[SensitiveParameter] string $key):string{
415-
416-
if(strlen($data) !== strlen($key)){
417-
throw new RuntimeException('The decryption key size and data size doesn\'t match');
418-
}
419-
420-
$data = unpack('C*', $data);
421-
$key = unpack('C*', $key);
422-
423-
foreach($data as $i => &$c){
424-
$c ^= $key[$i];
425-
}
426-
427-
return pack('C*', ...$data);
428-
}
429-
43087
}

0 commit comments

Comments
 (0)