[
  {
    "path": ".flake8",
    "content": "[flake8]\nignore = W191, E117\nmax-line-length = 88\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI tests\n\non: [push]\n\njobs:\n  tests:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: true\n      matrix:\n        python-version:\n          - \"3.7\"\n          - \"3.8\"\n          - \"3.9\"\n          - \"3.10\"\n\n    steps:\n    - uses: actions/checkout@v2\n    - name: Set up Python ${{ matrix.python-version }}\n      uses: actions/setup-python@v2\n      with:\n        python-version: ${{ matrix.python-version }}\n    - name: Install poetry\n      run: python -m pip install poetry\n    - name: Install application\n      run: poetry install\n    - name: Test with pytest\n      run: poetry run pytest\n\n  lint:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n      - uses: actions/setup-python@v2\n        with:\n          python-version: \"3.10\"\n      - name: Install poetry\n        run: python -m pip install poetry\n      - name: Install application\n        run: poetry install\n      - name: Check for flake8 issues\n        run: poetry run flake8 .\n      - name: Check code formatting with Black\n        run: poetry run tan . --check\n      - name: Check import ordering with isort\n        run: poetry run isort . --check --diff\n\n  mypy:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n      - uses: actions/setup-python@v2\n        with:\n          python-version: \"3.10\"\n      - name: Install poetry\n        run: python -m pip install poetry\n      - name: Install application\n        run: poetry install\n      - name: Check using mypy\n        run: poetry run mypy bna\n"
  },
  {
    "path": ".gitignore",
    "content": "*.py[co]\n__pycache__\nbuild\ndist\nMANIFEST\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) Jerome Leclanche\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# python-bna\n\n## Requirements\n\n- Python 3.6+\n\n\n## Command-line usage\n\nbna is a command line interface to the python-bna library. It can store\nand manage multiple authenticators, as well as create new ones.\n\n\nRemember: Using an authenticator on the same device as the one you log in with\nis less secure than keeping the devices separate. Use this at your own risk.\n\nConfiguration is stored in `~/.config/bna/bna.conf`. You can pass a\ndifferent config path with `bna --config=~/.bna.conf` for example.\n\n\n### Creating a new authenticator\n\n    $ bna new\n\nIf you do not already have an authenticator, it will be set as default.\nYou can pass `--set-default` otherwise.\n\n\n### Getting an authentication token\n\n    $ bna\n    01234567\n    $ bna EU-1234-1234-1234\n    76543210\n\n\n### Getting an authenticator's restore code\n\n    $ bna show-restore-code\n    Z45Q9CVXRR\n    $ bna restore EU-1234-1234-1234 ABCDE98765\n    Restored EU-1234-1234-1234\n\n\n### Getting an OTPAuth URL\n\nTo display the OTPAuth URL (used for setup QR Codes):\n\n    $ bna show-url\n    otpauth://totp/Blizzard:EU123412341234:?secret=ASFAS75ASDF75889G9AD7S69AS7697AS&issuer=Blizzard&digits=8\n\nNow paste this to your OTP app, or convert to QRCode and scan, or\nmanually enter the secret.\n\nThis is compatible with standard TOTP clients and password managers such as:\n- [andOTP](https://play.google.com/store/apps/details?id=org.shadowice.flocke.andotp) (Android),\n- [KeepassXC](https://keepassxc.org/) (Cross-platform)\n- [1Password](https://1password.com/) (Cross-platform)\n\n\n#### Getting a QR code\n\nTo encode to a QRCode on your local system install \\'qrencode\\'\n\nFor a PNG file saved to disk :\n\n    $ bna show-url | qrencode -o ~/BNA-qrcode.png\n    # Scan QRCode\n    $ rm ~/BNA-qrcode.png\n\nOr to attempt ot display QRCode in terminal as text output :\n\n    $ bna --otpauth-url | qrencode -t ANSI\n\n\n## Python library usage\n\n### Requesting a new authenticator\n\n```py\nimport bna\ntry:\n    # region is EU or US\n    # note that EU authenticators are valid in the US, and vice versa\n    serial, secret = bna.request_new_serial(\"US\")\nexcept bna.HTTPError as e:\n    print(\"Could not connect:\", e)\n```\n\n### Getting a token\n\n```py\n    # Get and print a token using PyOTP\n    from pyotp import TOTP\n    totp = TOTP(secret, digits=8)\n    print(totp.now())\n```\n"
  },
  {
    "path": "bna/__init__.py",
    "content": "\"\"\"\npython-bna\nBlizzard Authenticator routines in Python.\n\nSpecification can be found here:\n* <http://bnetauth.freeportal.us/specification.html>\nNote: Link likely dead. Check webarchive.\n\"\"\"\n\nimport pkg_resources\n\nfrom .crypto import get_restore_code\nfrom .http import HTTPError, get_time_offset, request_new_serial, restore\nfrom .utils import get_otpauth_url, normalize_serial, prettify_serial\n\n__all__ = [\n\t\"get_restore_code\",\n\t\"get_time_offset\",\n\t\"HTTPError\",\n\t\"request_new_serial\",\n\t\"restore\",\n\t\"get_otpauth_url\",\n\t\"normalize_serial\",\n\t\"prettify_serial\",\n]\n__version__ = pkg_resources.require(\"bna\")[0].version\n"
  },
  {
    "path": "bna/__main__.py",
    "content": "from .cli import main as __main__\n\n__all__ = [\"__main__\"]\n\n__main__()\n"
  },
  {
    "path": "bna/cli.py",
    "content": "import base64\nimport os\nimport sys\nfrom configparser import ConfigParser\nfrom string import hexdigits\nfrom time import sleep\nfrom typing import List\n\nimport click\nfrom pyotp import TOTP\n\nimport bna\n\n\ndef get_default_config_path() -> str:\n\t\"\"\"\n\tReturns the default configuration file path\n\t\"\"\"\n\tconfigdir = \"bna\"\n\thome = os.environ.get(\"HOME\", \"\")\n\tif os.name == \"posix\":\n\t\tbase = os.environ.get(\"XDG_CONFIG_HOME\", os.path.join(home, \".config\"))\n\t\tpath = os.path.join(base, configdir)\n\telif os.name == \"nt\":\n\t\tbase = os.environ[\"APPDATA\"]\n\t\tpath = os.path.join(base, configdir)\n\telse:\n\t\tpath = home\n\n\treturn os.path.join(path, \"bna.conf\")\n\n\ndef ishex(s: str) -> bool:\n\t\"\"\"\n\tReturns True if a string contains only hex digits, False otherwise.\n\t\"\"\"\n\treturn \"\".join(filter(lambda c: c in hexdigits, s)) == s\n\n\nclass AuthenticatorSerial(click.ParamType):\n\tname = \"serial\"\n\n\tdef convert(self, value: str, param, ctx) -> str:\n\t\tif not value:\n\t\t\tvalue = ctx.obj.get_default_serial()\n\t\t\tif not value:\n\t\t\t\tif not ctx.obj._serials():\n\t\t\t\t\tmsg = (\n\t\t\t\t\t\t\"You do not have any configured authenticators. \"\n\t\t\t\t\t\t\"Create a new one with 'bna new' or try \"\n\t\t\t\t\t\t\"'bna --help' for more information\"\n\t\t\t\t\t)\n\t\t\t\telse:\n\t\t\t\t\tmsg = (\n\t\t\t\t\t\t\"You do not have a default authenticator set. \"\n\t\t\t\t\t\t\"You must provide an authenticator serial or set a \"\n\t\t\t\t\t\t\"default one with 'bna set-default <serial>'.\"\n\t\t\t\t\t)\n\t\t\t\tctx.fail(msg)\n\n\t\tserial = bna.normalize_serial(value)\n\t\tif not ctx.obj.config.has_section(serial):\n\t\t\tctx.fail(f\"No such authenticator: {serial}\")\n\n\t\treturn serial\n\n\nclass App:\n\tdef __init__(self, config: str) -> None:\n\t\tself.config_file = os.path.expanduser(config) or get_default_config_path()\n\t\tconfig_dir = os.path.abspath(os.path.dirname(self.config_file))\n\t\tif not os.path.exists(config_dir):\n\t\t\tos.makedirs(config_dir)\n\n\t\tself.config = ConfigParser()\n\t\ttry:\n\t\t\tself.config.read([self.config_file])\n\t\texcept Exception as e:\n\t\t\tclick.echo(f\"Could not parse config file {self.config_file}: {e}\", err=True)\n\t\t\texit(1)\n\n\tdef _serials(self) -> List[str]:\n\t\treturn [x for x in self.config.sections() if x != \"bna\"]\n\n\tdef add_serial(self, serial: str, secret: str, set_default: bool) -> None:\n\t\tself.set_secret(serial, secret)\n\n\t\t# We set the serial as default if we don't have one set already\n\t\t# Otherwise, we check for --set-default\n\t\tif set_default or not self.get_default_serial():\n\t\t\tself.set_default_serial(serial)\n\n\tdef get_default_serial(self) -> str:\n\t\tif not self.config.has_option(\"bna\", \"default_serial\"):\n\t\t\treturn \"\"\n\t\treturn self.config.get(\"bna\", \"default_serial\")\n\n\tdef set_default_serial(self, serial) -> None:\n\t\tif not self.config.has_section(\"bna\"):\n\t\t\tself.config.add_section(\"bna\")\n\t\tself.config.set(\"bna\", \"default_serial\", serial)\n\t\tself.write_config()\n\n\tdef write_config(self) -> None:\n\t\ttry:\n\t\t\twith open(self.config_file, \"w\") as f:\n\t\t\t\tself.config.write(f)\n\t\texcept IOError as e:\n\t\t\tclick.echo(f\"Could not open {self.config_file} for writing: {e}\", err=True)\n\t\t\texit(1)\n\n\tdef get_secret(self, serial: str) -> str:\n\t\tif not self.config.has_section(serial):\n\t\t\treturn \"\"\n\n\t\tsecret = self.config.get(serial, \"secret\")\n\n\t\tif len(secret) == 40 and ishex(secret):\n\t\t\t# bna <= 4.0.0 saved secrets as hex instead of more standard base32\n\t\t\tsys.stderr.write(\"Found old format for secret store. Converting.\\n\")\n\t\t\t# decode old format\n\t\t\tsecret_bytes = bytes.fromhex(secret)\n\t\t\t# re-encode in new format\n\t\t\tsecret = base64.b32encode(secret_bytes).decode()\n\t\t\t# save to config (in base32)\n\t\t\tself.set_secret(serial, secret)\n\n\t\treturn secret\n\n\tdef set_secret(self, serial: str, secret: str) -> None:\n\t\tif not self.config.has_section(serial):\n\t\t\tself.config.add_section(serial)\n\t\tself.config.set(serial, \"secret\", secret)\n\t\tself.write_config()\n\n\nclass DefaultShowGroup(click.Group):\n\tdef parse_args(self, ctx: click.Context, args: List[str]):\n\t\tif not args:\n\t\t\targs.append(\"show\")\n\t\treturn super().parse_args(ctx, args)\n\n\n@click.group(cls=DefaultShowGroup)\n@click.option(\"--config\", default=\"\", help=\"Path to a different config file to use\")\n@click.version_option(bna.__version__)\n@click.pass_context\ndef main(ctx: click.Context, config: str) -> None:\n\tctx.obj = App(config)\n\n\n@main.command(help=\"Show the current authenticator code\")\n@click.argument(\"serial\", type=AuthenticatorSerial(), default=\"\")\n@click.option(\n\t\"--interactive/--no-interactive\",\n\tdefault=False,\n\thelp=\"interactive mode: updates the token as soon as it expires\",\n)\n@click.pass_context\ndef show(ctx: click.Context, serial: str, interactive: bool) -> None:\n\tsecret = ctx.obj.get_secret(serial)\n\ttotp = TOTP(secret, digits=8)\n\tif interactive:\n\t\tclick.echo(\"Ctrl-C to exit\")\n\t\twhile True:\n\t\t\ttoken = totp.now()\n\t\t\tsys.stdout.write(\"\\r\" + token)\n\t\t\tsys.stdout.flush()\n\t\t\tsleep(1)\n\telse:\n\t\tclick.echo(totp.now())\n\n\n@main.command(help=\"Request a new authenticator\")\n@click.option(\"--region\", default=\"US\", help=\"Desired region for the new authenticator\")\n@click.option(\"--set-default/--no-set-default\", default=True)\n@click.pass_context\ndef new(ctx: click.Context, region: str, set_default: bool) -> None:\n\ttry:\n\t\tserial, secret = bna.request_new_serial(region)\n\texcept bna.HTTPError as e:\n\t\tclick.echo(f\"Could not connect: {e}\", err=True)\n\t\texit(1)\n\n\tserial = bna.normalize_serial(serial)\n\tctx.obj.add_serial(serial, secret, set_default=set_default)\n\tclick.echo(f\"Success! Your new authenticator is: {bna.prettify_serial(serial)}\")\n\n\n@main.command(help=\"Delete an authenticator from the configuration\")\n@click.argument(\"serial\", type=AuthenticatorSerial())\n@click.pass_context\ndef delete(ctx: click.Context, serial: str) -> None:\n\tif not ctx.obj.config.has_section(serial):\n\t\tctx.fail(f\"No such serial: {serial}\")\n\tctx.obj.config.remove_section(serial)\n\n\t# If it's the default serial, remove that\n\tif serial == ctx.obj.get_default_serial():\n\t\tctx.obj.config.remove_option(\"bna\", \"default_serial\")\n\n\tctx.obj.write_config()\n\tclick.echo(f\"Deleted authenticator: {bna.prettify_serial(serial)}\")\n\n\n@main.command(help=\"Recover an authenticator from its restore code\")\n@click.argument(\"serial\")\n@click.argument(\"restore_code\")\n@click.option(\"--set-default/--no-set-default\", default=False)\n@click.pass_context\ndef restore(\n\tctx: click.Context, serial: str, restore_code: str, set_default: bool\n) -> None:\n\tif ctx.obj.config.has_option(serial, \"secret\"):\n\t\tctx.fail(\n\t\t\t\"A secret already exists for this serial. \"\n\t\t\tf\"Try deleting it first with bna delete {serial}\"\n\t\t)\n\n\tserial = bna.normalize_serial(serial)\n\ttry:\n\t\tsecret = bna.restore(serial, restore_code)\n\texcept ValueError as e:\n\t\tctx.fail(str(e))\n\n\tctx.obj.add_serial(serial, secret, set_default=set_default)\n\tclick.echo(f\"Restored {bna.prettify_serial(serial)}\")\n\n\n@main.command(help=\"List all configured authenticators\")\n@click.pass_context\ndef list(ctx) -> None:\n\tdefault = ctx.obj.get_default_serial()\n\tserials = ctx.obj._serials()\n\tfor serial in serials:\n\t\tif serial == default:\n\t\t\tclick.echo(f\"{serial} (default)\")\n\t\telse:\n\t\t\tclick.echo(serial)\n\n\tclick.echo(f\"{len(serials)} authenticators\")\n\n\n@main.command(help=\"Set an authenticator as the default one to use for commands\")\n@click.argument(\"serial\", type=AuthenticatorSerial(), default=\"\")\n@click.pass_context\ndef set_default(ctx: click.Context, serial: str) -> None:\n\tctx.obj.set_default_serial(serial)\n\tclick.echo(f\"{bna.prettify_serial(serial)} is now your default authenticator.\")\n\n\n@main.command(\"show-restore-code\", help=\"Display an authenticator's restore code\")\n@click.argument(\"serial\", type=AuthenticatorSerial(), default=\"\")\n@click.pass_context\ndef show_restore_code(ctx: click.Context, serial: str) -> None:\n\tsecret = ctx.obj.get_secret(serial)\n\tcode = bna.get_restore_code(serial, secret)\n\tclick.echo(code)\n\n\n@main.command(\"show-url\", help=\"Display an authenticator's OTPAuth URL (for QR codes)\")\n@click.argument(\"serial\", type=AuthenticatorSerial(), default=\"\")\n@click.pass_context\ndef show_url(ctx: click.Context, serial: str) -> None:\n\tsecret = ctx.obj.get_secret(serial)\n\n\t# Only add a newline if stdout is a tty\n\tnewline = sys.stdout.isatty()\n\tclick.echo(bna.get_otpauth_url(serial, secret), nl=newline)\n\n\n@main.command(\"show-secret\", help=\"Display an authenticator's secret\")\n@click.argument(\"serial\", type=AuthenticatorSerial(), default=\"\")\n@click.pass_context\ndef show_secret(ctx: click.Context, serial: str) -> None:\n\tsecret = ctx.obj.get_secret(serial)\n\tclick.echo(secret)\n\n\nif __name__ == \"__main__\":\n\tmain()\n"
  },
  {
    "path": "bna/constants.py",
    "content": "RSA_MOD = 104890018807986556874007710914205443157030159668034197186125678960287470894290830530618284943118405110896322835449099433232093151168250152146023319326491587651685252774820340995950744075665455681760652136576493028733914892166700899109836291180881063097461175643998356321993663868233366705340758102567742483097  # noqa\nRSA_KEY = 257\n\nENROLL_HOSTS = {\n\t\"CN\": \"mobile-service.battlenet.com.cn\",\n\t# \"EU\": \"m.eu.mobileservice.blizzard.com\",\n\t# \"US\": \"m.us.mobileservice.blizzard.com\",\n\t# \"EU\": \"eu.mobile-service.blizzard.com\",\n\t# \"US\": \"us.mobile-service.blizzard.com\",\n\t\"default\": \"mobile-service.blizzard.com\",\n}\n"
  },
  {
    "path": "bna/crypto.py",
    "content": "from base64 import b32decode\nfrom hashlib import sha1\nfrom typing import Union\n\nfrom .constants import RSA_KEY, RSA_MOD\n\n\ndef encrypt(data: bytes) -> str:\n\tbase_num = int(data.hex(), 16)\n\tn = base_num ** RSA_KEY % RSA_MOD\n\tret = \"\"\n\twhile n > 0:\n\t\tn, m = divmod(n, 256)\n\t\tret = chr(m) + ret\n\treturn ret\n\n\ndef decrypt(response: bytes, otp: bytes) -> bytearray:\n\tret = bytearray()\n\tfor c, e in zip(response, otp):\n\t\tret.append(c ^ e)\n\treturn ret\n\n\ndef bytes_to_restore_code(digest: Union[bytes, bytearray]) -> str:\n\tret = []\n\tfor i in digest:\n\t\tc = i & 0x1F\n\t\tif c < 10:\n\t\t\tc += 48\n\t\telse:\n\t\t\tc += 55\n\t\t\tif c > 72:  # I\n\t\t\t\tc += 1\n\t\t\tif c > 75:  # L\n\t\t\t\tc += 1\n\t\t\tif c > 78:  # O\n\t\t\t\tc += 1\n\t\t\tif c > 82:  # S\n\t\t\t\tc += 1\n\t\tret.append(chr(c))\n\n\treturn \"\".join(ret)\n\n\ndef get_restore_code(serial: str, secret: str) -> str:\n\tsecret_bytes = b32decode(secret)\n\tdata = serial.encode() + secret_bytes\n\tdigest = sha1(data).digest()[-10:]\n\treturn bytes_to_restore_code(digest)\n\n\ndef restore_code_to_bytes(code: str) -> bytes:\n\tret = bytearray()\n\tfor c in code:\n\t\ti = ord(c)\n\t\tif 58 > i > 47:\n\t\t\ti -= 48\n\t\telse:\n\t\t\tmod = i - 55\n\t\t\tif i > 72:\n\t\t\t\tmod -= 1\n\t\t\tif i > 75:\n\t\t\t\tmod -= 1\n\t\t\tif i > 78:\n\t\t\t\tmod -= 1\n\t\t\tif i > 82:\n\t\t\t\tmod -= 1\n\t\t\ti = mod\n\t\tret.append(i)\n\n\treturn bytes(ret)\n"
  },
  {
    "path": "bna/http.py",
    "content": "import hmac\nimport struct\nfrom base64 import b32encode\nfrom hashlib import sha1\nfrom http.client import HTTPConnection\nfrom secrets import token_bytes\nfrom time import time\nfrom typing import Optional, Tuple\n\nfrom .constants import ENROLL_HOSTS\nfrom .crypto import decrypt, encrypt, restore_code_to_bytes\nfrom .utils import normalize_serial\n\n\nclass HTTPError(Exception):\n\tdef __init__(self, msg, response):\n\t\tself.response = response\n\t\tsuper().__init__(msg)\n\n\nclass APIClient:\n\tdef __init__(self, *, region: str = \"US\", host: str = \"\"):\n\t\tself.region = region\n\t\tself.host = host or ENROLL_HOSTS.get(region, ENROLL_HOSTS[\"default\"])\n\n\tdef post(self, path: str, *, data: Optional[str] = None) -> bytes:\n\t\tconn = HTTPConnection(self.host)\n\t\tconn.request(\"POST\", path, data)\n\t\tresponse = conn.getresponse()\n\n\t\tif response.status != 200:\n\t\t\traise HTTPError(\n\t\t\t\t\"%s returned status %i\" % (self.host, response.status), response\n\t\t\t)\n\n\t\tret = response.read()\n\t\tconn.close()\n\t\treturn ret\n\n\tdef enroll(self, data):\n\t\treturn self.post(\"/enrollment/enroll.htm\", data=data)\n\n\tdef get_time(self) -> int:\n\t\tresponse = self.post(\"/enrollment/time.htm\")\n\t\treturn int(struct.unpack(\">Q\", response)[0])\n\n\tdef initiate_paper_restore(self, serial: str):\n\t\tresponse = self.post(\"/enrollment/initiatePaperRestore.htm\", data=serial)\n\t\tresp_size = len(response)\n\t\tif resp_size != 32:\n\t\t\traise ValueError(\"Bad challenge response (%i bytes)\" % (resp_size))\n\n\t\treturn response\n\n\tdef validate_paper_restore(self, serial: str, encrypted_data: str):\n\t\tdata = serial + encrypted_data\n\t\ttry:\n\t\t\tresponse = self.post(\"/enrollment/validatePaperRestore.htm\", data=data)\n\t\texcept HTTPError as e:\n\t\t\tif e.response.status == 600:\n\t\t\t\traise HTTPError(\"Invalid serial or restore key\", e.response)\n\t\t\telse:\n\t\t\t\traise\n\t\treturn response\n\n\ndef request_new_serial(\n\tregion: str = \"US\", model: str = \"Motorola RAZR v3\"\n) -> Tuple[str, str]:\n\t\"\"\"\n\tRequests a new authenticator\n\tThis will connect to the Blizzard servers\n\t\"\"\"\n\n\tdef base_msg(otp, region, model):\n\t\tret = (otp + b\"\\0\" * 37)[:37]\n\t\tret += region.encode() or b\"\\0\\0\"\n\t\tret += (model.encode() + b\"\\0\" * 16)[:16]\n\t\treturn b\"\\1\" + ret\n\n\totp = token_bytes(37)\n\tdata = base_msg(otp, region, model)\n\tencrypted_data = encrypt(data)\n\n\tclient = APIClient(region=region)\n\tresponse = client.enroll(encrypted_data)[8:]\n\n\tdecrypted_response = decrypt(response, otp)\n\n\tsecret = b32encode(decrypted_response[:20]).decode()\n\tserial = decrypted_response[20:].decode()\n\n\tregion = serial[:2]\n\tif region not in (\"CN\", \"EU\", \"KR\", \"US\"):\n\t\traise ValueError(\"Unexpected region: %r\" % (region))\n\n\treturn serial, secret\n\n\ndef get_time_offset(region: str = \"US\") -> int:\n\t\"\"\"\n\tCalculates the time difference in seconds as a float\n\tbetween the local host and a remote server\n\n\tThis function returns the difference in milliseconds as an int.\n\tNegative numbers indicate the local clock is ahead of the\n\tserver clock.\n\t\"\"\"\n\tclient = APIClient(region=region)\n\tserver_time = client.get_time()\n\tlocal_time = time()\n\n\t# NOTE: The server returns time in milliseconds as an int whereas\n\t# Python returns it as a float, in seconds.\n\treturn server_time - int(local_time * 1000)\n\n\ndef restore(serial: str, restore_code: str) -> str:\n\tserial = normalize_serial(serial)\n\trestore_code = restore_code.upper()\n\tif len(restore_code) != 10:\n\t\traise ValueError(\n\t\t\tf\"invalid restore code (should be 10 characters): {restore_code}\"\n\t\t)\n\n\t# Region is always the first two chars of a restore code\n\tregion = serial[:2]\n\tclient = APIClient(region=region)\n\tchallenge = client.initiate_paper_restore(serial)\n\n\tcode = restore_code_to_bytes(restore_code)\n\thash = hmac.new(code, serial.encode() + challenge, digestmod=sha1).digest()\n\n\totp = token_bytes(20)\n\tencrypted_data = encrypt(hash + otp)\n\tresponse = client.validate_paper_restore(serial, encrypted_data)\n\tsecret = decrypt(response, otp)\n\n\treturn b32encode(secret).decode()\n"
  },
  {
    "path": "bna/utils.py",
    "content": "\"\"\"\nUtility functions\n\"\"\"\nfrom pyotp import TOTP\n\n\ndef normalize_serial(serial: str) -> str:\n\t\"\"\"\n\tNormalizes a serial\n\tWill uppercase it, remove its dashes and strip\n\tany whitespace\n\t\"\"\"\n\treturn serial.upper().replace(\"-\", \"\").strip()\n\n\ndef prettify_serial(serial: str) -> str:\n\t\"\"\"\n\tReturns the prettified version of a serial\n\tIt should look like XX-AAAA-BBBB-CCCC-DDDD\n\t\"\"\"\n\tserial = normalize_serial(serial)\n\tif len(serial) != 14:\n\t\traise ValueError(\"serial %r should be 14 characters long\" % (serial))\n\n\tdef digits(chars):\n\t\tif not chars.isdigit():\n\t\t\traise ValueError(\"bad serial %r\" % (serial))\n\t\treturn \"%04i\" % int((chars))\n\n\treturn \"%s%s%s%s\" % (\n\t\tserial[0:2].upper(),\n\t\tdigits(serial[2:6]),\n\t\tdigits(serial[6:10]),\n\t\tdigits(serial[10:14]),\n\t)\n\n\ndef get_otpauth_url(serial: str, secret: str) -> str:\n\t\"\"\"\n\tGet the OTPAuth URL for the serial/secret pair\n\thttps://github.com/google/google-authenticator/wiki/Key-Uri-Format\n\t\"\"\"\n\ttotp = TOTP(secret, digits=8)\n\n\treturn totp.provisioning_uri(serial, issuer_name=\"Blizzard\")\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.poetry]\nname = \"bna\"\nversion = \"5.1.0\"\ndescription = \"Blizzard Authenticator and OTP library in Python\"\nauthors = [\"Jerome Leclanche <jerome@leclan.ch>\"]\nlicense = \"MIT\"\nreadme = \"README.md\"\nrepository = \"https://github.com/jleclanche/python-bna\"\nclassifiers = [\n\t\"Development Status :: 5 - Production/Stable\",\n\t\"Environment :: Console\",\n\t\"Intended Audience :: Developers\",\n\t\"Intended Audience :: End Users/Desktop\",\n\t\"Topic :: Security\",\n\t\"Topic :: Security :: Cryptography\",\n]\n\n[tool.poetry.dependencies]\npython = \"^3.7\"\nclick = \"^8.0.3\"\npyotp = \"^2.6.0\"\n\n[tool.poetry.dev-dependencies]\nflake8 = \"^4.0.1\"\nisort = \"^5.10.1\"\nmypy = \"^0.931\"\npytest = \"^7.0.0\"\ntypes-setuptools = \"^57.4.9\"\ntan = \"^21.14\"\n\n[tool.poetry.scripts]\nbna = \"bna.cli:main\"\n\n[build-system]\nrequires = [\"poetry>=0.12\"]\nbuild-backend = \"poetry.masonry.api\"\n\n[tool.black]\nuse-tabs = true\n\n[tool.isort]\nprofile = \"black\"\nindent = \"tab\"\n"
  },
  {
    "path": "tests/test_main.py",
    "content": "#!/usr/bin/env python\n\nimport urllib.parse\n\nfrom pyotp import TOTP\n\nimport bna\n\nSERIAL = \"US120910711868\"\nSECRET = \"HA4GCYLGMFRWKNBYGI4TCZJQHFSGGMLFMNSTSYZSMFQTINDEHAZTSOJYGNQTOZTG\"\n\n\ndef test_token():\n\ttotp = TOTP(SECRET, digits=8)\n\tassert totp.at(1347279358) == \"93461643\"\n\tassert totp.at(1347279359) == \"93461643\"\n\tassert totp.at(1347279360) == \"86031001\"\n\n\ndef test_restore_code():\n\tassert bna.get_restore_code(SERIAL, SECRET) == \"4B91NQCYQ3\"\n\n\ndef test_serial():\n\tpretty_serial = bna.prettify_serial(SERIAL.lower())\n\tassert pretty_serial == \"US120910711868\"\n\tassert bna.normalize_serial(pretty_serial) == SERIAL\n\n\ndef test_otpauth_url():\n\totpauth_url = bna.get_otpauth_url(SERIAL, SECRET)\n\tp = urllib.parse.urlparse(otpauth_url)\n\tassert p.scheme == \"otpauth\"\n\tassert p.netloc == \"totp\"\n\tassert p.path == \"/Blizzard:%s\" % (SERIAL)\n\tparams = urllib.parse.parse_qs(p.query)\n\tassert params[\"secret\"] == [SECRET]\n\tassert params[\"issuer\"] == [\"Blizzard\"]\n\tassert params[\"digits\"] == [\"8\"]\n"
  }
]