Repository: jleclanche/python-bna Branch: master Commit: 8116e41a87b8 Files: 14 Total size: 22.2 KB Directory structure: gitextract_40tpyfoq/ ├── .flake8 ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── bna/ │ ├── __init__.py │ ├── __main__.py │ ├── cli.py │ ├── constants.py │ ├── crypto.py │ ├── http.py │ └── utils.py ├── pyproject.toml └── tests/ └── test_main.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .flake8 ================================================ [flake8] ignore = W191, E117 max-line-length = 88 ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI tests on: [push] jobs: tests: runs-on: ubuntu-latest strategy: fail-fast: true matrix: python-version: - "3.7" - "3.8" - "3.9" - "3.10" steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install poetry run: python -m pip install poetry - name: Install application run: poetry install - name: Test with pytest run: poetry run pytest lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: "3.10" - name: Install poetry run: python -m pip install poetry - name: Install application run: poetry install - name: Check for flake8 issues run: poetry run flake8 . - name: Check code formatting with Black run: poetry run tan . --check - name: Check import ordering with isort run: poetry run isort . --check --diff mypy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: "3.10" - name: Install poetry run: python -m pip install poetry - name: Install application run: poetry install - name: Check using mypy run: poetry run mypy bna ================================================ FILE: .gitignore ================================================ *.py[co] __pycache__ build dist MANIFEST ================================================ FILE: LICENSE ================================================ Copyright (c) Jerome Leclanche Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # python-bna ## Requirements - Python 3.6+ ## Command-line usage bna is a command line interface to the python-bna library. It can store and manage multiple authenticators, as well as create new ones. Remember: Using an authenticator on the same device as the one you log in with is less secure than keeping the devices separate. Use this at your own risk. Configuration is stored in `~/.config/bna/bna.conf`. You can pass a different config path with `bna --config=~/.bna.conf` for example. ### Creating a new authenticator $ bna new If you do not already have an authenticator, it will be set as default. You can pass `--set-default` otherwise. ### Getting an authentication token $ bna 01234567 $ bna EU-1234-1234-1234 76543210 ### Getting an authenticator's restore code $ bna show-restore-code Z45Q9CVXRR $ bna restore EU-1234-1234-1234 ABCDE98765 Restored EU-1234-1234-1234 ### Getting an OTPAuth URL To display the OTPAuth URL (used for setup QR Codes): $ bna show-url otpauth://totp/Blizzard:EU123412341234:?secret=ASFAS75ASDF75889G9AD7S69AS7697AS&issuer=Blizzard&digits=8 Now paste this to your OTP app, or convert to QRCode and scan, or manually enter the secret. This is compatible with standard TOTP clients and password managers such as: - [andOTP](https://play.google.com/store/apps/details?id=org.shadowice.flocke.andotp) (Android), - [KeepassXC](https://keepassxc.org/) (Cross-platform) - [1Password](https://1password.com/) (Cross-platform) #### Getting a QR code To encode to a QRCode on your local system install \'qrencode\' For a PNG file saved to disk : $ bna show-url | qrencode -o ~/BNA-qrcode.png # Scan QRCode $ rm ~/BNA-qrcode.png Or to attempt ot display QRCode in terminal as text output : $ bna --otpauth-url | qrencode -t ANSI ## Python library usage ### Requesting a new authenticator ```py import bna try: # region is EU or US # note that EU authenticators are valid in the US, and vice versa serial, secret = bna.request_new_serial("US") except bna.HTTPError as e: print("Could not connect:", e) ``` ### Getting a token ```py # Get and print a token using PyOTP from pyotp import TOTP totp = TOTP(secret, digits=8) print(totp.now()) ``` ================================================ FILE: bna/__init__.py ================================================ """ python-bna Blizzard Authenticator routines in Python. Specification can be found here: * Note: Link likely dead. Check webarchive. """ import pkg_resources from .crypto import get_restore_code from .http import HTTPError, get_time_offset, request_new_serial, restore from .utils import get_otpauth_url, normalize_serial, prettify_serial __all__ = [ "get_restore_code", "get_time_offset", "HTTPError", "request_new_serial", "restore", "get_otpauth_url", "normalize_serial", "prettify_serial", ] __version__ = pkg_resources.require("bna")[0].version ================================================ FILE: bna/__main__.py ================================================ from .cli import main as __main__ __all__ = ["__main__"] __main__() ================================================ FILE: bna/cli.py ================================================ import base64 import os import sys from configparser import ConfigParser from string import hexdigits from time import sleep from typing import List import click from pyotp import TOTP import bna def get_default_config_path() -> str: """ Returns the default configuration file path """ configdir = "bna" home = os.environ.get("HOME", "") if os.name == "posix": base = os.environ.get("XDG_CONFIG_HOME", os.path.join(home, ".config")) path = os.path.join(base, configdir) elif os.name == "nt": base = os.environ["APPDATA"] path = os.path.join(base, configdir) else: path = home return os.path.join(path, "bna.conf") def ishex(s: str) -> bool: """ Returns True if a string contains only hex digits, False otherwise. """ return "".join(filter(lambda c: c in hexdigits, s)) == s class AuthenticatorSerial(click.ParamType): name = "serial" def convert(self, value: str, param, ctx) -> str: if not value: value = ctx.obj.get_default_serial() if not value: if not ctx.obj._serials(): msg = ( "You do not have any configured authenticators. " "Create a new one with 'bna new' or try " "'bna --help' for more information" ) else: msg = ( "You do not have a default authenticator set. " "You must provide an authenticator serial or set a " "default one with 'bna set-default '." ) ctx.fail(msg) serial = bna.normalize_serial(value) if not ctx.obj.config.has_section(serial): ctx.fail(f"No such authenticator: {serial}") return serial class App: def __init__(self, config: str) -> None: self.config_file = os.path.expanduser(config) or get_default_config_path() config_dir = os.path.abspath(os.path.dirname(self.config_file)) if not os.path.exists(config_dir): os.makedirs(config_dir) self.config = ConfigParser() try: self.config.read([self.config_file]) except Exception as e: click.echo(f"Could not parse config file {self.config_file}: {e}", err=True) exit(1) def _serials(self) -> List[str]: return [x for x in self.config.sections() if x != "bna"] def add_serial(self, serial: str, secret: str, set_default: bool) -> None: self.set_secret(serial, secret) # We set the serial as default if we don't have one set already # Otherwise, we check for --set-default if set_default or not self.get_default_serial(): self.set_default_serial(serial) def get_default_serial(self) -> str: if not self.config.has_option("bna", "default_serial"): return "" return self.config.get("bna", "default_serial") def set_default_serial(self, serial) -> None: if not self.config.has_section("bna"): self.config.add_section("bna") self.config.set("bna", "default_serial", serial) self.write_config() def write_config(self) -> None: try: with open(self.config_file, "w") as f: self.config.write(f) except IOError as e: click.echo(f"Could not open {self.config_file} for writing: {e}", err=True) exit(1) def get_secret(self, serial: str) -> str: if not self.config.has_section(serial): return "" secret = self.config.get(serial, "secret") if len(secret) == 40 and ishex(secret): # bna <= 4.0.0 saved secrets as hex instead of more standard base32 sys.stderr.write("Found old format for secret store. Converting.\n") # decode old format secret_bytes = bytes.fromhex(secret) # re-encode in new format secret = base64.b32encode(secret_bytes).decode() # save to config (in base32) self.set_secret(serial, secret) return secret def set_secret(self, serial: str, secret: str) -> None: if not self.config.has_section(serial): self.config.add_section(serial) self.config.set(serial, "secret", secret) self.write_config() class DefaultShowGroup(click.Group): def parse_args(self, ctx: click.Context, args: List[str]): if not args: args.append("show") return super().parse_args(ctx, args) @click.group(cls=DefaultShowGroup) @click.option("--config", default="", help="Path to a different config file to use") @click.version_option(bna.__version__) @click.pass_context def main(ctx: click.Context, config: str) -> None: ctx.obj = App(config) @main.command(help="Show the current authenticator code") @click.argument("serial", type=AuthenticatorSerial(), default="") @click.option( "--interactive/--no-interactive", default=False, help="interactive mode: updates the token as soon as it expires", ) @click.pass_context def show(ctx: click.Context, serial: str, interactive: bool) -> None: secret = ctx.obj.get_secret(serial) totp = TOTP(secret, digits=8) if interactive: click.echo("Ctrl-C to exit") while True: token = totp.now() sys.stdout.write("\r" + token) sys.stdout.flush() sleep(1) else: click.echo(totp.now()) @main.command(help="Request a new authenticator") @click.option("--region", default="US", help="Desired region for the new authenticator") @click.option("--set-default/--no-set-default", default=True) @click.pass_context def new(ctx: click.Context, region: str, set_default: bool) -> None: try: serial, secret = bna.request_new_serial(region) except bna.HTTPError as e: click.echo(f"Could not connect: {e}", err=True) exit(1) serial = bna.normalize_serial(serial) ctx.obj.add_serial(serial, secret, set_default=set_default) click.echo(f"Success! Your new authenticator is: {bna.prettify_serial(serial)}") @main.command(help="Delete an authenticator from the configuration") @click.argument("serial", type=AuthenticatorSerial()) @click.pass_context def delete(ctx: click.Context, serial: str) -> None: if not ctx.obj.config.has_section(serial): ctx.fail(f"No such serial: {serial}") ctx.obj.config.remove_section(serial) # If it's the default serial, remove that if serial == ctx.obj.get_default_serial(): ctx.obj.config.remove_option("bna", "default_serial") ctx.obj.write_config() click.echo(f"Deleted authenticator: {bna.prettify_serial(serial)}") @main.command(help="Recover an authenticator from its restore code") @click.argument("serial") @click.argument("restore_code") @click.option("--set-default/--no-set-default", default=False) @click.pass_context def restore( ctx: click.Context, serial: str, restore_code: str, set_default: bool ) -> None: if ctx.obj.config.has_option(serial, "secret"): ctx.fail( "A secret already exists for this serial. " f"Try deleting it first with bna delete {serial}" ) serial = bna.normalize_serial(serial) try: secret = bna.restore(serial, restore_code) except ValueError as e: ctx.fail(str(e)) ctx.obj.add_serial(serial, secret, set_default=set_default) click.echo(f"Restored {bna.prettify_serial(serial)}") @main.command(help="List all configured authenticators") @click.pass_context def list(ctx) -> None: default = ctx.obj.get_default_serial() serials = ctx.obj._serials() for serial in serials: if serial == default: click.echo(f"{serial} (default)") else: click.echo(serial) click.echo(f"{len(serials)} authenticators") @main.command(help="Set an authenticator as the default one to use for commands") @click.argument("serial", type=AuthenticatorSerial(), default="") @click.pass_context def set_default(ctx: click.Context, serial: str) -> None: ctx.obj.set_default_serial(serial) click.echo(f"{bna.prettify_serial(serial)} is now your default authenticator.") @main.command("show-restore-code", help="Display an authenticator's restore code") @click.argument("serial", type=AuthenticatorSerial(), default="") @click.pass_context def show_restore_code(ctx: click.Context, serial: str) -> None: secret = ctx.obj.get_secret(serial) code = bna.get_restore_code(serial, secret) click.echo(code) @main.command("show-url", help="Display an authenticator's OTPAuth URL (for QR codes)") @click.argument("serial", type=AuthenticatorSerial(), default="") @click.pass_context def show_url(ctx: click.Context, serial: str) -> None: secret = ctx.obj.get_secret(serial) # Only add a newline if stdout is a tty newline = sys.stdout.isatty() click.echo(bna.get_otpauth_url(serial, secret), nl=newline) @main.command("show-secret", help="Display an authenticator's secret") @click.argument("serial", type=AuthenticatorSerial(), default="") @click.pass_context def show_secret(ctx: click.Context, serial: str) -> None: secret = ctx.obj.get_secret(serial) click.echo(secret) if __name__ == "__main__": main() ================================================ FILE: bna/constants.py ================================================ RSA_MOD = 104890018807986556874007710914205443157030159668034197186125678960287470894290830530618284943118405110896322835449099433232093151168250152146023319326491587651685252774820340995950744075665455681760652136576493028733914892166700899109836291180881063097461175643998356321993663868233366705340758102567742483097 # noqa RSA_KEY = 257 ENROLL_HOSTS = { "CN": "mobile-service.battlenet.com.cn", # "EU": "m.eu.mobileservice.blizzard.com", # "US": "m.us.mobileservice.blizzard.com", # "EU": "eu.mobile-service.blizzard.com", # "US": "us.mobile-service.blizzard.com", "default": "mobile-service.blizzard.com", } ================================================ FILE: bna/crypto.py ================================================ from base64 import b32decode from hashlib import sha1 from typing import Union from .constants import RSA_KEY, RSA_MOD def encrypt(data: bytes) -> str: base_num = int(data.hex(), 16) n = base_num ** RSA_KEY % RSA_MOD ret = "" while n > 0: n, m = divmod(n, 256) ret = chr(m) + ret return ret def decrypt(response: bytes, otp: bytes) -> bytearray: ret = bytearray() for c, e in zip(response, otp): ret.append(c ^ e) return ret def bytes_to_restore_code(digest: Union[bytes, bytearray]) -> str: ret = [] for i in digest: c = i & 0x1F if c < 10: c += 48 else: c += 55 if c > 72: # I c += 1 if c > 75: # L c += 1 if c > 78: # O c += 1 if c > 82: # S c += 1 ret.append(chr(c)) return "".join(ret) def get_restore_code(serial: str, secret: str) -> str: secret_bytes = b32decode(secret) data = serial.encode() + secret_bytes digest = sha1(data).digest()[-10:] return bytes_to_restore_code(digest) def restore_code_to_bytes(code: str) -> bytes: ret = bytearray() for c in code: i = ord(c) if 58 > i > 47: i -= 48 else: mod = i - 55 if i > 72: mod -= 1 if i > 75: mod -= 1 if i > 78: mod -= 1 if i > 82: mod -= 1 i = mod ret.append(i) return bytes(ret) ================================================ FILE: bna/http.py ================================================ import hmac import struct from base64 import b32encode from hashlib import sha1 from http.client import HTTPConnection from secrets import token_bytes from time import time from typing import Optional, Tuple from .constants import ENROLL_HOSTS from .crypto import decrypt, encrypt, restore_code_to_bytes from .utils import normalize_serial class HTTPError(Exception): def __init__(self, msg, response): self.response = response super().__init__(msg) class APIClient: def __init__(self, *, region: str = "US", host: str = ""): self.region = region self.host = host or ENROLL_HOSTS.get(region, ENROLL_HOSTS["default"]) def post(self, path: str, *, data: Optional[str] = None) -> bytes: conn = HTTPConnection(self.host) conn.request("POST", path, data) response = conn.getresponse() if response.status != 200: raise HTTPError( "%s returned status %i" % (self.host, response.status), response ) ret = response.read() conn.close() return ret def enroll(self, data): return self.post("/enrollment/enroll.htm", data=data) def get_time(self) -> int: response = self.post("/enrollment/time.htm") return int(struct.unpack(">Q", response)[0]) def initiate_paper_restore(self, serial: str): response = self.post("/enrollment/initiatePaperRestore.htm", data=serial) resp_size = len(response) if resp_size != 32: raise ValueError("Bad challenge response (%i bytes)" % (resp_size)) return response def validate_paper_restore(self, serial: str, encrypted_data: str): data = serial + encrypted_data try: response = self.post("/enrollment/validatePaperRestore.htm", data=data) except HTTPError as e: if e.response.status == 600: raise HTTPError("Invalid serial or restore key", e.response) else: raise return response def request_new_serial( region: str = "US", model: str = "Motorola RAZR v3" ) -> Tuple[str, str]: """ Requests a new authenticator This will connect to the Blizzard servers """ def base_msg(otp, region, model): ret = (otp + b"\0" * 37)[:37] ret += region.encode() or b"\0\0" ret += (model.encode() + b"\0" * 16)[:16] return b"\1" + ret otp = token_bytes(37) data = base_msg(otp, region, model) encrypted_data = encrypt(data) client = APIClient(region=region) response = client.enroll(encrypted_data)[8:] decrypted_response = decrypt(response, otp) secret = b32encode(decrypted_response[:20]).decode() serial = decrypted_response[20:].decode() region = serial[:2] if region not in ("CN", "EU", "KR", "US"): raise ValueError("Unexpected region: %r" % (region)) return serial, secret def get_time_offset(region: str = "US") -> int: """ Calculates the time difference in seconds as a float between the local host and a remote server This function returns the difference in milliseconds as an int. Negative numbers indicate the local clock is ahead of the server clock. """ client = APIClient(region=region) server_time = client.get_time() local_time = time() # NOTE: The server returns time in milliseconds as an int whereas # Python returns it as a float, in seconds. return server_time - int(local_time * 1000) def restore(serial: str, restore_code: str) -> str: serial = normalize_serial(serial) restore_code = restore_code.upper() if len(restore_code) != 10: raise ValueError( f"invalid restore code (should be 10 characters): {restore_code}" ) # Region is always the first two chars of a restore code region = serial[:2] client = APIClient(region=region) challenge = client.initiate_paper_restore(serial) code = restore_code_to_bytes(restore_code) hash = hmac.new(code, serial.encode() + challenge, digestmod=sha1).digest() otp = token_bytes(20) encrypted_data = encrypt(hash + otp) response = client.validate_paper_restore(serial, encrypted_data) secret = decrypt(response, otp) return b32encode(secret).decode() ================================================ FILE: bna/utils.py ================================================ """ Utility functions """ from pyotp import TOTP def normalize_serial(serial: str) -> str: """ Normalizes a serial Will uppercase it, remove its dashes and strip any whitespace """ return serial.upper().replace("-", "").strip() def prettify_serial(serial: str) -> str: """ Returns the prettified version of a serial It should look like XX-AAAA-BBBB-CCCC-DDDD """ serial = normalize_serial(serial) if len(serial) != 14: raise ValueError("serial %r should be 14 characters long" % (serial)) def digits(chars): if not chars.isdigit(): raise ValueError("bad serial %r" % (serial)) return "%04i" % int((chars)) return "%s%s%s%s" % ( serial[0:2].upper(), digits(serial[2:6]), digits(serial[6:10]), digits(serial[10:14]), ) def get_otpauth_url(serial: str, secret: str) -> str: """ Get the OTPAuth URL for the serial/secret pair https://github.com/google/google-authenticator/wiki/Key-Uri-Format """ totp = TOTP(secret, digits=8) return totp.provisioning_uri(serial, issuer_name="Blizzard") ================================================ FILE: pyproject.toml ================================================ [tool.poetry] name = "bna" version = "5.1.0" description = "Blizzard Authenticator and OTP library in Python" authors = ["Jerome Leclanche "] license = "MIT" readme = "README.md" repository = "https://github.com/jleclanche/python-bna" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Topic :: Security", "Topic :: Security :: Cryptography", ] [tool.poetry.dependencies] python = "^3.7" click = "^8.0.3" pyotp = "^2.6.0" [tool.poetry.dev-dependencies] flake8 = "^4.0.1" isort = "^5.10.1" mypy = "^0.931" pytest = "^7.0.0" types-setuptools = "^57.4.9" tan = "^21.14" [tool.poetry.scripts] bna = "bna.cli:main" [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" [tool.black] use-tabs = true [tool.isort] profile = "black" indent = "tab" ================================================ FILE: tests/test_main.py ================================================ #!/usr/bin/env python import urllib.parse from pyotp import TOTP import bna SERIAL = "US120910711868" SECRET = "HA4GCYLGMFRWKNBYGI4TCZJQHFSGGMLFMNSTSYZSMFQTINDEHAZTSOJYGNQTOZTG" def test_token(): totp = TOTP(SECRET, digits=8) assert totp.at(1347279358) == "93461643" assert totp.at(1347279359) == "93461643" assert totp.at(1347279360) == "86031001" def test_restore_code(): assert bna.get_restore_code(SERIAL, SECRET) == "4B91NQCYQ3" def test_serial(): pretty_serial = bna.prettify_serial(SERIAL.lower()) assert pretty_serial == "US120910711868" assert bna.normalize_serial(pretty_serial) == SERIAL def test_otpauth_url(): otpauth_url = bna.get_otpauth_url(SERIAL, SECRET) p = urllib.parse.urlparse(otpauth_url) assert p.scheme == "otpauth" assert p.netloc == "totp" assert p.path == "/Blizzard:%s" % (SERIAL) params = urllib.parse.parse_qs(p.query) assert params["secret"] == [SECRET] assert params["issuer"] == ["Blizzard"] assert params["digits"] == ["8"]